diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml index 931db3bd..f3590067 100644 --- a/.github/ISSUE_TEMPLATE/application-bug.yml +++ b/.github/ISSUE_TEMPLATE/application-bug.yml @@ -80,13 +80,13 @@ body: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: + - label: I am sure my issue is related to the app and **NOT some extension**. + required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. required: true - label: I have written a short but informative title. required: true - label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**. required: true - - label: If related to a provider, I have checked the site and it works, but not the app. - required: true - label: I will fill out all of the requested information in this form. required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 250734cd..b56cdf8e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Request a new provider or report bug with an existing provider url: https://github.com/recloudstream - about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord. + about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord. - name: Discord url: https://discord.gg/5Hus6fM about: Join our discord for faster support on smaller issues. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 9c35ba56..e18daebb 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -27,9 +27,7 @@ body: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: + - label: My suggestion is **NOT** about adding a new provider + required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - required: true - - label: I have written a short but informative title. - required: true - - label: I will fill out all of the requested information in this form. - required: true + required: true \ No newline at end of file diff --git a/.github/downloads.jpg b/.github/downloads.jpg deleted file mode 100644 index ca14a664..00000000 Binary files a/.github/downloads.jpg and /dev/null differ diff --git a/.github/home.jpg b/.github/home.jpg deleted file mode 100644 index 72370d3c..00000000 Binary files a/.github/home.jpg and /dev/null differ diff --git a/.github/locales.py b/.github/locales.py index 1c79c093..a74d7258 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,6 +1,8 @@ import re import glob import requests +import os +import lxml.etree as ET # builtin library doesn't preserve comments SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" @@ -45,4 +47,23 @@ open(SETTINGS_PATH, "w+",encoding='utf-8').write( "\n" + END_MARKER + after_src -) \ No newline at end of file +) + +# Go through each values.xml file and fix escaped \@string +for file in glob.glob(f"{XML_NAME}*/strings.xml"): + try: + tree = ET.parse(file) + for child in tree.getroot(): + if not child.text: + continue + if child.text.startswith("\\@string/"): + print(f"[{file}] fixing {child.attrib['name']}") + child.text = child.text.replace("\\@string/", "@string/") + with open(file, 'wb') as fp: + fp.write(b'\n') + tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) + # Remove trailing new line to be consistent with weblate + fp.seek(-1, os.SEEK_END) + fp.truncate() + except ET.ParseError as ex: + print(f"[{file}] {ex}") diff --git a/.github/player.jpg b/.github/player.jpg deleted file mode 100644 index f6959cf3..00000000 Binary files a/.github/player.jpg and /dev/null differ diff --git a/.github/results.jpg b/.github/results.jpg deleted file mode 100644 index 4dbc9b8d..00000000 Binary files a/.github/results.jpg and /dev/null differ diff --git a/.github/search.jpg b/.github/search.jpg deleted file mode 100644 index 784bec89..00000000 Binary files a/.github/search.jpg and /dev/null differ diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 83430766..e84bb08b 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -19,23 +19,23 @@ jobs: steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - name: Generate access token (archive) id: generate_archive_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream-archive" - - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -56,7 +56,9 @@ jobs: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} - - uses: actions/checkout@v3 + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + - uses: actions/checkout@v4 with: repository: "recloudstream/cloudstream-archive" token: ${{ steps.generate_archive_token.outputs.token }} diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index 3c5caad7..96e61644 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} @@ -42,13 +42,14 @@ jobs: cd $GITHUB_WORKSPACE/dokka/ rm -rf "./-cloudstream" - - name: Setup JDK 11 - uses: actions/setup-java@v1 + - name: Setup JDK 17 + uses: actions/setup-java@v4 with: - java-version: 11 + java-version: 17 + distribution: 'adopt' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@v3 - name: Generate Dokka run: | diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml index 108cec82..88ab3656 100644 --- a/.github/workflows/issue_action.yml +++ b/.github/workflows/issue_action.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} @@ -27,7 +27,7 @@ jobs: comment-body: '${index}. ${similarity} #${number}' - name: Label if possible duplicate if: steps.similarity.outputs.similar-issues-found =='true' - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ steps.generate_token.outputs.token }} script: | @@ -37,7 +37,7 @@ jobs: repo: context.repo.repo, labels: ["possible duplicate"] }) - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Automatically close issues that dont follow the issue template uses: lucasbento/auto-close-issues@v1.0.2 with: @@ -68,7 +68,7 @@ jobs: Found provider name: `${{ steps.provider_check.outputs.name }}` - name: Label if mentions provider if: steps.provider_check.outputs.name != 'none' - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ steps.generate_token.outputs.token }} script: | diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 4ce7dba1..f35cd58c 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -18,16 +18,16 @@ jobs: steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -43,11 +43,14 @@ jobs: echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - name: Run Gradle run: | - ./gradlew assemblePrerelease makeJar androidSourcesJar + ./gradlew assemblePrerelease build androidSourcesJar + ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - name: Create pre-release uses: "marvinpinto/action-automatic-releases@latest" with: diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 36199cd6..7f6dd412 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -6,18 +6,18 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Run Gradle run: ./gradlew assemblePrereleaseDebug - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index 93cdca44..ce140e55 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,4 +1,4 @@ -name: Update locale lists +name: Fix locale issues on: workflow_dispatch: @@ -9,7 +9,7 @@ on: - master concurrency: - group: "locale-list" + group: "locale" cancel-in-progress: true jobs: @@ -18,14 +18,17 @@ jobs: steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: token: ${{ steps.generate_token.outputs.token }} + - name: Install dependencies + run: | + pip3 install lxml - name: Edit files run: | python3 .github/locales.py @@ -35,5 +38,5 @@ jobs: git config --local user.name "recloudstream[bot]" git add . # "echo" returns true so the build succeeds, even if no changed files - git commit -m 'update list of locales' || echo + git commit -m 'chore(locales): fix locale issues' || echo git push diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 5421743a..b589d56e 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + - + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 10c26704..d7c08c9c 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,17 +4,16 @@ diff --git a/README.md b/README.md index 3430d626..8949304e 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,11 @@ + **AdFree**, No ads whatsoever + No tracking/analytics + Bookmarks -+ Download and stream movies, tv-shows and anime ++ Phone and TV support + Chromecast - -### Screenshots: - - - ++ Extension system for personal customization ### Supported languages: Translation status - \ No newline at end of file + diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 00000000..7f7fd14c --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,6 @@ +# Set this to the minimum version your project supports. +cmake_minimum_required(VERSION 3.18) +project(CrashHandler) +find_library(log-lib log) +add_library(native-lib SHARED src/main/cpp/native-lib.cpp) +target_link_libraries(native-lib ${log-lib}) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c855d28..d0c86bab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,13 +1,14 @@ -import com.android.build.gradle.api.BaseVariantOutput +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.jetbrains.dokka.gradle.DokkaTask +import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.ByteArrayOutputStream import java.net.URL plugins { id("com.android.application") + id("com.google.devtools.ksp") id("kotlin-android") - id("kotlin-kapt") - id("kotlin-android-extensions") id("org.jetbrains.dokka") } @@ -19,7 +20,7 @@ fun String.execute() = ByteArrayOutputStream().use { baot -> workingDir = projectDir commandLine = this@execute.split(Regex("\\s")) standardOutput = baot - }.exitValue == 0) + }.exitValue == 0) String(baot.toByteArray()).trim() else null } @@ -28,9 +29,21 @@ android { testOptions { unitTests.isReturnDefaultValues = true } + + viewBinding { + enable = true + } + + /* disable this for now + externalNativeBuild { + cmake { + path("CMakeLists.txt") + } + }*/ + signingConfigs { - create("prerelease") { - if (prereleaseStoreFile != null) { + if (prereleaseStoreFile != null) { + create("prerelease") { storeFile = file(prereleaseStoreFile) storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") @@ -39,33 +52,44 @@ android { } } - compileSdk = 33 - buildToolsVersion = "30.0.3" + compileSdk = 34 + buildToolsVersion = "34.0.0" defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = 21 - targetSdk = 33 - - versionCode = 57 - versionName = "4.0.0" + targetSdk = 33 /* Android 14 is Fu*ked + ^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/ + versionCode = 64 + versionName = "4.4.0" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") - resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") - resValue("bool", "is_prerelease", "false") + // Reads local.properties + val localProperties = gradleLocalProperties(rootDir) + + buildConfigField( + "long", + "BUILD_DATE", + "${System.currentTimeMillis()}" + ) buildConfigField( "String", - "BUILDDATE", - "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" + "SIMKL_CLIENT_ID", + "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\"" + ) + buildConfigField( + "String", + "SIMKL_CLIENT_SECRET", + "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - kapt { - includeCompileClasspath = true + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("exportSchema", "true") } } @@ -74,14 +98,21 @@ android { isDebuggable = false isMinifyEnabled = false isShrinkResources = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } debug { isDebuggable = true applicationIdSuffix = ".debug" - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } + flavorDimensions.add("state") productFlavors { create("stable") { @@ -93,25 +124,31 @@ android { resValue("bool", "is_prerelease", "true") buildConfigField("boolean", "BETA", "true") applicationIdSuffix = ".prerelease" - signingConfig = signingConfigs.getByName("prerelease") + if (signingConfigs.names.contains("prerelease")) { + signingConfig = signingConfigs.getByName("prerelease") + } else { + logger.warn("No prerelease signing config!") + } versionNameSuffix = "-PRE" versionCode = (System.currentTimeMillis() / 60000).toInt() } } + compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "1.8" - freeCompilerArgs = listOf("-Xjvm-default=compatibility") - } + lint { abortOnError = false checkReleaseBuilds = false } + + buildFeatures { + buildConfig = true + } + namespace = "com.lagradost.cloudstream3" } @@ -120,122 +157,132 @@ repositories { } dependencies { - implementation("com.google.android.mediahome:video:1.0.0") - implementation("androidx.test.ext:junit-ktx:1.1.3") - testImplementation("org.json:json:20180813") - - implementation("androidx.core:core-ktx:1.8.0") - implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0 - - // dont change this to 1.6.0 it looks ugly af - implementation("com.google.android.material:material:1.5.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.navigation:navigation-fragment-ktx:2.5.1") - implementation("androidx.navigation:navigation-ui-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") + // Testing testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + testImplementation("org.json:json:20240303") + androidTestImplementation("androidx.test:core") + implementation("androidx.test.ext:junit-ktx:1.2.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - //implementation("io.karn:khttp-android:0.1.2") //okhttp instead -// implementation("org.jsoup:jsoup:1.13.1") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") - - implementation("androidx.preference:preference-ktx:1.2.0") - - implementation("com.github.bumptech.glide:glide:4.13.1") - kapt("com.github.bumptech.glide:compiler:4.13.1") - implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0") + // Android Core & Lifecycle + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.navigation:navigation-ui-ktx:2.7.7") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.7") + // Design & UI implementation("jp.wasabeef:glide-transformations:4.3.0") - + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09") + // Glide Module + ksp("com.github.bumptech.glide:ksp:4.16.0") + implementation("com.github.bumptech.glide:glide:4.16.0") + implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0") - // Exoplayer - implementation("com.google.android.exoplayer:exoplayer:2.18.2") - implementation("com.google.android.exoplayer:extension-cast:2.18.2") - implementation("com.google.android.exoplayer:extension-mediasession:2.18.2") - implementation("com.google.android.exoplayer:extension-okhttp:2.18.2") + // For KSP -> Official Annotation Processors are Not Yet Supported for KSP + ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0") + implementation("com.google.guava:guava:33.2.1-android") + implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0") - //implementation("com.google.android.exoplayer:extension-leanback:2.14.0") + // Media 3 (ExoPlayer) + implementation("androidx.media3:media3-ui:1.1.1") + implementation("androidx.media3:media3-cast:1.1.1") + implementation("androidx.media3:media3-common:1.1.1") + implementation("androidx.media3:media3-session:1.1.1") + implementation("androidx.media3:media3-exoplayer:1.1.1") + implementation("com.google.android.mediahome:video:1.0.0") + implementation("androidx.media3:media3-exoplayer-hls:1.1.1") + implementation("androidx.media3:media3-exoplayer-dash:1.1.1") + implementation("androidx.media3:media3-datasource-okhttp:1.1.1") - // Bug reports - implementation("ch.acra:acra-core:5.8.4") - implementation("ch.acra:acra-toast:5.8.4") + // PlayBack + implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker + implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs + implementation("com.github.teamnewpipe:NewPipeExtractor:176da72") /* For Trailers + ^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */ + implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding - compileOnly("com.google.auto.service:auto-service-annotations:1.0") - //either for java sources: - annotationProcessor("com.google.auto.service:auto-service:1.0") - //or for kotlin sources (requires kapt gradle plugin): - kapt("com.google.auto.service:auto-service:1.0") - - // subtitle color picker - implementation("com.jaredrummler:colorpicker:1.1.0") - - //run JS - // do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not - // available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown - implementation("org.mozilla:rhino:1.7.13") - - // TorrentStream - //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0") - - // Downloading - implementation("androidx.work:work-runtime:2.7.1") - implementation("androidx.work:work-runtime-ktx:2.7.1") - - // Networking -// implementation("com.squareup.okhttp3:okhttp:4.9.2") -// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1") - implementation("com.github.Blatzar:NiceHttp:0.4.2") - // To fix SSL fuckery on android 9 - implementation("org.conscrypt:conscrypt-android:2.2.1") - // Util to skip the URI file fuckery 🙏 - implementation("com.github.tachiyomiorg:unifile:17bec43") - - // API because cba maintaining it myself - implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0") - - implementation("com.github.discord:OverlappingPanels:0.1.3") - // debugImplementation because LeakCanary should only run in debug builds. - // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' - - // for shimmer when loading - implementation("com.facebook.shimmer:shimmer:0.5.0") + // Crash Reports (AcraApplication.kt) + implementation("ch.acra:acra-core:5.11.3") + implementation("ch.acra:acra-toast:5.11.3") + // UI Stuff + implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton) + implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors implementation("androidx.tvprovider:tvprovider:1.0.0") + implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures + implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication + implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview + implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV - // used for subtitle decoding https://github.com/albfernandez/juniversalchardet - implementation("com.github.albfernandez:juniversalchardet:2.4.0") + // Extensions & Other Libs + implementation("org.mozilla:rhino:1.7.15") // run JavaScript + implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance + implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery + implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9 + implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser + ^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API + Level 25 or Less. */ - // slow af yt - //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT") + // Downloading & Networking + implementation("androidx.work:work-runtime:2.9.0") + implementation("androidx.work:work-runtime-ktx:2.9.0") + implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib - // newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L190 - implementation("com.github.TeamNewPipe:NewPipeExtractor:9ffdd0948b2ecd82655f5ff2a3e127b2b7695d5b") - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") + implementation(project(":library") { + // There does not seem to be a good way of getting the android flavor. + val isDebug = gradle.startParameter.taskRequests.any { task -> + task.args.any { arg -> + arg.contains("debug", true) + } + } - // Library/extensions searching with Levenshtein distance - implementation("me.xdrop:fuzzywuzzy:1.4.0") - - // color pallette for images -> colors - implementation("androidx.palette:palette-ktx:1.0.0") + this.extra.set("isDebug", isDebug) + }) } -tasks.register("androidSourcesJar", Jar::class) { +tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) //full sources + from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources } -// this is used by the gradlew plugin -tasks.register("makeJar", Copy::class) { - from("build/intermediates/compile_app_classes_jar/prereleaseDebug") - into("build") - include("classes.jar") - dependsOn("build") +tasks.register("copyJar") { + from( + "build/intermediates/compile_app_classes_jar/prereleaseDebug", + "../library/build/libs" + ) + into("build/app-classes") + include("classes.jar", "library-jvm*.jar") + // Remove the version + rename("library-jvm.*.jar", "library-jvm.jar") +} + +// Merge the app classes and the library classes into classes.jar +tasks.register("makeJar") { + // Duplicates cause hard to catch errors, better to fail at compile time. + duplicatesStrategy = DuplicatesStrategy.FAIL + dependsOn(tasks.getByName("copyJar")) + from( + zipTree("build/app-classes/classes.jar"), + zipTree("build/app-classes/library-jvm.jar") + ) + destinationDirectory.set(layout.buildDirectory) + archivesName = "classes" +} + +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs = listOf("-Xjvm-default=all-compatibility") + } } tasks.withType().configureEach { @@ -248,6 +295,7 @@ tasks.withType().configureEach { // URL showing where the source code can be accessed through the web browser remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java")) + // Suffix which is used to append the line number to the URL. Use #L for GitHub remoteLineSuffix.set("#L") } diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 81753f6b..c7f02baf 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -1,155 +1,57 @@ package com.lagradost.cloudstream3 +import android.app.Activity +import android.os.Bundle +import android.os.PersistableBundle +import android.view.LayoutInflater +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.Qualities +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.databinding.FragmentHomeBinding +import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding +import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding +import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding +import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding +import com.lagradost.cloudstream3.databinding.FragmentResultBinding +import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding +import com.lagradost.cloudstream3.databinding.FragmentSearchBinding +import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding +import com.lagradost.cloudstream3.databinding.HomeResultGridBinding +import com.lagradost.cloudstream3.databinding.HomepageParentBinding +import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding +import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding +import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding +import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding +import com.lagradost.cloudstream3.databinding.RepositoryItemBinding +import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding +import com.lagradost.cloudstream3.databinding.SearchResultGridBinding +import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding +import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith + /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ +class TestApplication : Activity() { + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onCreate(savedInstanceState, persistentState) + } +} + @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { - //@Test - //fun useAppContext() { - // // Context of the app under test. - // val appContext = InstrumentationRegistry.getInstrumentation().targetContext - // assertEquals("com.lagradost.cloudstream3", appContext.packageName) - //} - - private fun getAllProviders(): List { - return APIHolder.allProviders //.filter { !it.usesWebView } - } - - private suspend fun loadLinks(api: MainAPI, url: String?): Boolean { - Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) - if (url == null) return true - var linksLoaded = 0 - try { - val success = api.loadLinks(url, false, {}) { link -> - Assert.assertTrue( - "Api ${api.name} returns link with invalid Quality", - Qualities.values().map { it.value }.contains(link.quality) - ) - Assert.assertTrue( - "Api ${api.name} returns link with invalid url ${link.url}", - link.url.length > 4 - ) - linksLoaded++ - } - if (success) { - return linksLoaded > 0 - } - Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success) - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider has not implemented .loadLinks") - } - logError(e) - } - return true - } - - private suspend fun testSingleProviderApi(api: MainAPI): Boolean { - val searchQueries = listOf("over", "iron", "guy") - var correctResponses = 0 - var searchResult: List? = null - for (query in searchQueries) { - val response = try { - api.search(query) - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider has not implemented .search") - } - logError(e) - null - } - if (!response.isNullOrEmpty()) { - correctResponses++ - if (searchResult == null) { - searchResult = response - } - } - } - - if (correctResponses == 0 || searchResult == null) { - System.err.println("Api ${api.name} did not return any valid search responses") - return false - } - - try { - var validResults = false - for (result in searchResult) { - Assert.assertEquals( - "Invalid apiName on response on ${api.name}", - result.apiName, - api.name - ) - val load = api.load(result.url) ?: continue - Assert.assertEquals( - "Invalid apiName on load on ${api.name}", - load.apiName, - result.apiName - ) - Assert.assertTrue( - "Api ${api.name} on load does not contain any of the supportedTypes", - api.supportedTypes.contains(load.type) - ) - when (load) { - is AnimeLoadResponse -> { - val gotNoEpisodes = - load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() } - - if (gotNoEpisodes) { - println("Api ${api.name} got no episodes on ${load.url}") - continue - } - - val url = (load.episodes[load.episodes.keys.first()])?.first()?.data - validResults = loadLinks(api, url) - if (!validResults) continue - } - is MovieLoadResponse -> { - val gotNoEpisodes = load.dataUrl.isBlank() - if (gotNoEpisodes) { - println("Api ${api.name} got no movie on ${load.url}") - continue - } - - validResults = loadLinks(api, load.dataUrl) - if (!validResults) continue - } - is TvSeriesLoadResponse -> { - val gotNoEpisodes = load.episodes.isEmpty() - if (gotNoEpisodes) { - println("Api ${api.name} got no episodes on ${load.url}") - continue - } - - validResults = loadLinks(api, load.episodes.first().data) - if (!validResults) continue - } - } - break - } - if (!validResults) { - System.err.println("Api ${api.name} did not load on any") - } - - return validResults - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider has not implemented .load") - } - logError(e) - return false - } + private fun getAllProviders(): Array { + println("Providers: ${APIHolder.allProviders.size}") + return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView } } @Test @@ -158,7 +60,78 @@ class ExampleInstrumentedTest { println("Done providersExist") } + @Throws + private inline fun testAllLayouts( + activity: Activity, + vararg layouts: Int + ) { + + val bind = T::class.java.methods.first { it.name == "bind" } + val inflater = LayoutInflater.from(activity) + for (layout in layouts) { + val root = inflater.inflate(layout, null, false) + bind.invoke(null, root) + } + } + @Test + @Throws + fun layoutTest() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity: MainActivity -> + // FragmentHomeHeadBinding and FragmentHomeHeadTvBinding CANT be the same + //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv) + //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv) + + // main cant be tested + // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv) + // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv) + //testAllLayouts(activity, R.layout.activity_main_tv) + + testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv) + testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv) + + // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv) + // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv) + + testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) + testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) + testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) + + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + + testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home) + testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home) + + testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search) + testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search) + + testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) + //testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) ??? fails ??? + + testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded) + testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded) + + + // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv) + // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv) + + testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) + testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) + testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) + + testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library) + testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library) + } + } + } + + @Test + @Throws(AssertionError::class) fun providerCorrectData() { val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 } Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty()) @@ -180,68 +153,20 @@ class ExampleInstrumentedTest { @Test fun providerCorrectHomepage() { runBlocking { - getAllProviders().amap { api -> - if (api.hasMainPage) { - try { - val f = api.mainPage.first() - val homepage = - api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) - when { - homepage == null -> { - System.err.println("Homepage provider ${api.name} did not correctly load homepage!") - } - homepage.items.isEmpty() -> { - System.err.println("Homepage provider ${api.name} does not contain any items!") - } - homepage.items.any { it.list.isEmpty() } -> { - System.err.println("Homepage provider ${api.name} does not have any items on result!") - } - } - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") - } - logError(e) - } - } + getAllProviders().toList().amap { api -> + TestingUtils.testHomepage(api, TestingUtils.Logger()) } } println("Done providerCorrectHomepage") } -// @Test -// fun testSingleProvider() { -// testSingleProviderApi(ThenosProvider()) -// } - @Test - fun providerCorrect() { + fun testAllProvidersCorrect() { runBlocking { - val invalidProvider = ArrayList>() - val providers = getAllProviders() - providers.amap { api -> - try { - println("Trying $api") - if (testSingleProviderApi(api)) { - println("Success $api") - } else { - System.err.println("Error $api") - invalidProvider.add(Pair(api, null)) - } - } catch (e: Exception) { - logError(e) - invalidProvider.add(Pair(api, e)) - } - } - if (invalidProvider.isEmpty()) { - println("No Invalid providers! :D") - } else { - println("Invalid providers are: ") - for (provider in invalidProvider) { - println("${provider.first}") - } - } + TestingUtils.getDeferredProviderTests( + this, + getAllProviders(), + ) { _, _ -> } } - println("Done providerCorrect") } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 871c4f69..888be999 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ - + @@ -14,8 +14,14 @@ + + + + + - + tools:targetApi="tiramisu"> + android:supportsPictureInPicture="true" + android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer" + android:launchMode="singleTask"> @@ -87,16 +97,20 @@ --> - - - - + + + + + + + + @@ -151,6 +165,21 @@ + + + + + + + + + + + + @@ -158,13 +187,14 @@ - + android:exported="false"> + @@ -174,6 +204,7 @@ android:exported="false" /> diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp new file mode 100644 index 00000000..f4cb531f --- /dev/null +++ b/app/src/main/cpp/native-lib.cpp @@ -0,0 +1,28 @@ +#include +#include +#include + +#define TAG "CloudStream Crash Handler" +volatile sig_atomic_t gSignalStatus = 0; +void handleNativeCrash(int signal) { + gSignalStatus = signal; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) { + #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash); + REGISTER_SIGNAL(SIGSEGV) + #undef REGISTER_SIGNAL +} + +//extern "C" JNIEXPORT void JNICALL +//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) { +// int *p = nullptr; +// *p = 0; +//} + +extern "C" JNIEXPORT int JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) { + //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus); + return gSignalStatus; +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index 0351b1ff..d6f978fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -8,12 +8,14 @@ import android.content.Intent import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import com.google.auto.service.AutoService +import com.lagradost.api.setContext import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.openBrowser +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys @@ -32,27 +34,26 @@ import org.acra.sender.ReportSenderFactory import java.io.File import java.io.FileNotFoundException import java.io.PrintStream -import java.lang.Exception import java.lang.ref.WeakReference +import java.util.Locale import kotlin.concurrent.thread import kotlin.system.exitProcess - class CustomReportSender : ReportSender { // Sends all your crashes to google forms override fun send(context: Context, errorContent: CrashReportData) { println("Sending report") val url = - "https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse" + "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse" val data = mapOf( - "entry.753293084" to errorContent.toJSON() + "entry.1993829403" to errorContent.toJSON() ) thread { // to not run it on main thread runBlocking { suspendSafeApiCall { - val post = app.post(url, data = data) - println("Report response: $post") + app.post(url, data = data) + //println("Report response: $post") } } } @@ -65,7 +66,6 @@ class CustomReportSender : ReportSender { } } -@AutoService(ReportSenderFactory::class) class CustomSenderFactory : ReportSenderFactory { override fun create(context: Context, config: CoreConfiguration): ReportSender { return CustomReportSender() @@ -82,14 +82,8 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : ACRA.errorReporter.handleException(error) try { PrintStream(errorFile).use { ps -> - ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")) - ps.println( - String.format( - "Fatal exception on thread %s (%d)", - thread.name, - thread.id - ) - ) + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} (${thread.id})") error.printStackTrace(ps) } } catch (ignored: FileNotFoundException) { @@ -104,12 +98,16 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : } class AcraApplication : Application() { + override fun onCreate() { super.onCreate() - Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) { + ExceptionHandler(filesDir.resolve("last_error")) { val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) startActivity(Intent.makeRestartActivityTask(intent!!.component)) - }) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } } override fun attachBaseContext(base: Context?) { @@ -121,10 +119,10 @@ class AcraApplication : Application() { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON - reportContent = arrayOf( + reportContent = listOf( ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, - ReportField.STACK_TRACE + ReportField.STACK_TRACE, ) // removed this due to bug when starting the app, moved it to when it actually crashes @@ -137,6 +135,8 @@ class AcraApplication : Application() { } companion object { + var exceptionHandler: ExceptionHandler? = null + /** Use to get activity from Context */ tailrec fun Context.getActivity(): Activity? = this as? Activity ?: (this as? ContextWrapper)?.baseContext?.getActivity() @@ -146,8 +146,17 @@ class AcraApplication : Application() { get() = _context?.get() private set(value) { _context = WeakReference(value) + setContext(WeakReference(value)) } + fun getKeyClass(path: String, valueType: Class): T? { + return context?.getKey(path, valueType) + } + + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + fun removeKeys(folder: String): Int? { return context?.removeKeys(folder) } @@ -199,10 +208,9 @@ class AcraApplication : Application() { fun openBrowser(url: String, activity: FragmentActivity?) { openBrowser( url, - isTvSettings(), + isLayout(TV or EMULATOR), activity?.supportFragmentManager?.fragments?.lastOrNull() ) } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 89f0ae51..ee3a5d12 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -5,11 +5,16 @@ import android.app.Activity import android.app.PictureInPictureParams import android.content.Context import android.content.pm.PackageManager +import android.content.res.Configuration import android.content.res.Resources import android.os.Build +import android.util.DisplayMetrics import android.util.Log -import android.view.* -import android.widget.TextView +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.View.NO_ID +import android.view.ViewGroup import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts @@ -18,15 +23,21 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat +import androidx.core.view.children import androidx.preference.PreferenceManager import com.google.android.gms.cast.framework.CastSession +import com.google.android.material.chip.ChipGroup +import com.google.android.material.navigationrail.NavigationRailView import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.MainActivity.Companion.resumeApps +import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.PlayerEventType import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.UiText -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv +import com.lagradost.cloudstream3.ui.settings.Globals.updateTv +import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.UIHelper @@ -34,14 +45,50 @@ import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode import com.lagradost.cloudstream3.utils.UIHelper.toPx import org.schabi.newpipe.extractor.NewPipe -import java.util.* +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +enum class FocusDirection { + Start, + End, + Up, + Down, +} object CommonActivity { + + private var _activity: WeakReference? = null + var activity + get() = _activity?.get() + private set(value) { + _activity = WeakReference(value) + } + + @MainThread + fun setActivityInstance(newActivity: Activity?) { + activity = newActivity + } + @MainThread fun Activity?.getCastSession(): CastSession? { return (this as MainActivity?)?.mSessionManager?.currentCastSession } + val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics + + // screenWidth and screenHeight does always + // refer to the screen while in landscape mode + val screenWidth: Int + get() { + return max(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + val screenHeight: Int + get() { + return min(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + var canEnterPipMode: Boolean = false var canShowPipMode: Boolean = false @@ -53,9 +100,32 @@ object CommonActivity { var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null + private var currentToast: Toast? = null - var currentToast: Toast? = null + fun showToast(@StringRes message: Int, duration: Int? = null) { + val act = activity ?: return + act.runOnUiThread { + showToast(act, act.getString(message), duration) + } + } + fun showToast(message: String?, duration: Int? = null) { + val act = activity ?: return + act.runOnUiThread { + showToast(act, message, duration) + } + } + + fun showToast(message: UiText?, duration: Int? = null) { + val act = activity ?: return + if (message == null) return + act.runOnUiThread { + showToast(act, message.asString(act), duration) + } + } + + + @MainThread fun showToast(act: Activity?, text: UiText, duration: Int) { if (act == null) return text.asStringNull(act)?.let { @@ -86,25 +156,19 @@ object CommonActivity { } catch (e: Exception) { logError(e) } + try { - val inflater = - act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater - - val layout: View = inflater.inflate( - R.layout.toast, - act.findViewById(R.id.toast_layout_root) as ViewGroup? - ) - - val text = layout.findViewById(R.id.text) as TextView - text.text = message.trim() + val binding = ToastBinding.inflate(act.layoutInflater) + binding.text.text = message.trim() + // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11) val toast = Toast(act) - toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) toast.duration = duration ?: Toast.LENGTH_SHORT - toast.view = layout - //https://github.com/PureWriter/ToastCompat - toast.show() + toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) + toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version. currentToast = toast + toast.show() + } catch (e: Exception) { logError(e) } @@ -138,22 +202,25 @@ object CommonActivity { setLocale(this, localeCode) } - fun init(act: ComponentActivity?) { - if (act == null) return + fun init(act: Activity) { + setActivityInstance(act) + + val componentActivity = activity as? ComponentActivity ?: return + //https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission //https://developer.android.com/guide/topics/ui/picture-in-picture canShowPipMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT - act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN - act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS + componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN + componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS - act.updateLocale() - act.updateTv() + componentActivity.updateLocale() + componentActivity.updateTv() NewPipe.init(DownloaderTestImpl.getInstance()) for (resumeApp in resumeApps) { resumeApp.launcher = - act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val resultCode = result.resultCode val data = result.data if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) { @@ -170,11 +237,11 @@ object CommonActivity { // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( - act, + componentActivity, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { - val requestPermissionLauncher = act.registerForActivityResult( + val requestPermissionLauncher = componentActivity.registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> Log.d(TAG, "Notification permission: $isGranted") @@ -210,30 +277,57 @@ object CommonActivity { } } + fun updateTheme(act: Activity) { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) + if (settingsManager + .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System" + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + loadThemes(act) + } + } + + private fun mapSystemTheme(act: Activity): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val currentNightMode = + act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return when (currentNightMode) { + Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme + else -> R.style.AppTheme // Night mode is active, we're using dark theme + } + } else { + return R.style.AppTheme + } + } + fun loadThemes(act: Activity?) { if (act == null) return val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) val currentTheme = when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) { + "System" -> mapSystemTheme(act) "Black" -> R.style.AppTheme "Light" -> R.style.LightMode "Amoled" -> R.style.AmoledMode "AmoledLight" -> R.style.AmoledModeLight "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.MonetMode else R.style.AppTheme + else -> R.style.AppTheme } val currentOverlayTheme = when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) { "Normal" -> R.style.OverlayPrimaryColorNormal + "DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow "CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink + "Orange" -> R.style.OverlayPrimaryColorOrange "DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen "Maroon" -> R.style.OverlayPrimaryColorMaroon "NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue "Grey" -> R.style.OverlayPrimaryColorGrey "White" -> R.style.OverlayPrimaryColorWhite + "CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue "Brown" -> R.style.OverlayPrimaryColorBrown "Purple" -> R.style.OverlayPrimaryColorPurple "Green" -> R.style.OverlayPrimaryColorGreen @@ -242,10 +336,13 @@ object CommonActivity { "Banana" -> R.style.OverlayPrimaryColorBanana "Party" -> R.style.OverlayPrimaryColorParty "Pink" -> R.style.OverlayPrimaryColorPink + "Lavender" -> R.style.OverlayPrimaryColorLavender "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal + "Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal + else -> R.style.OverlayPrimaryColorNormal } act.theme.applyStyle(currentTheme, true) @@ -257,101 +354,179 @@ object CommonActivity { ) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW } - private fun getNextFocus( - act: Activity?, + /** because we want closes find, aka when multiple have the same id, we go to parent + until the correct one is found */ + private fun localLook(from: View, id: Int): View? { + if (id == NO_ID) return null + var currentLook: View = from + // limit to 15 look depth + for (i in 0..15) { + currentLook.findViewById(id)?.let { return it } + currentLook = (currentLook.parent as? View) ?: break + } + return null + } + /*var currentLook: View = view + while (true) { + val tmpNext = currentLook.findViewById(nextId) + if (tmpNext != null) { + next = tmpNext + break + } + currentLook = currentLook.parent as? View ?: break + }*/ + + private fun View.hasContent(): Boolean { + return isShown && when (this) { + //is RecyclerView -> this.childCount > 0 + is ViewGroup -> this.childCount > 0 + else -> true + } + } + + /** skips the initial stage of searching for an id using the view, see getNextFocus for specification */ + fun continueGetNextFocus( + root: Any?, + view: View, + direction: FocusDirection, + nextId: Int, + depth: Int = 0 + ): View? { + if (nextId == NO_ID) return null + + // do an initial search for the view, in case the localLook is too deep we can use this as + // an early break and backup view + var next = + when (root) { + is Activity -> root.findViewById(nextId) + is View -> root.rootView.findViewById(nextId) + else -> null + } ?: return null + + next = localLook(view, nextId) ?: next + val shown = next.hasContent() + + // if cant focus but visible then break and let android decide + // the exception if is the view is a parent and has children that wants focus + val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> + parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0 + } ?: false + if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null + + // if not shown then continue because we will "skip" over views to get to a replacement + if (!shown) { + // we don't want a while true loop, so we let android decide if we find a recursive view + if (next == view) return null + return getNextFocus(root, next, direction, depth + 1) + } + + (when (next) { + is ChipGroup -> { + next.children.firstOrNull { it.isFocusable && it.isShown } + } + + is NavigationRailView -> { + next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home) + } + + else -> null + })?.let { + return it + } + + // nothing wrong with the view found, return it + return next + } + + /** recursively looks for a next focus up to a depth of 10, + * this is used to override the normal shit focus system + * because this application has a lot of invisible views that messes with some tv devices*/ + fun getNextFocus( + root: Any?, view: View?, direction: FocusDirection, depth: Int = 0 - ): Int? { - if (view == null || depth >= 10 || act == null) { + ): View? { + // if input is invalid let android decide + depth test to not crash if loop is found + if (view == null || depth >= 10 || root == null) { return null } - val nextId = when (direction) { - FocusDirection.Left -> { - view.nextFocusLeftId + var nextId = when (direction) { + FocusDirection.Start -> { + if (view.isRtl()) + view.nextFocusRightId + else + view.nextFocusLeftId } + FocusDirection.Up -> { view.nextFocusUpId } - FocusDirection.Right -> { - view.nextFocusRightId + + FocusDirection.End -> { + if (view.isRtl()) + view.nextFocusLeftId + else + view.nextFocusRightId } + FocusDirection.Down -> { view.nextFocusDownId } } - return if (nextId != -1) { - val next = act.findViewById(nextId) - //println("NAME: ${next.accessibilityClassName} | ${next?.isShown}" ) - - if (next?.isShown == false) { - getNextFocus(act, next, direction, depth + 1) - } else { - if (depth == 0) { - null - } else { - nextId - } - } - } else { - null + if (nextId == NO_ID) { + // if not specified then use forward id + nextId = view.nextFocusForwardId + // if view is still not found to next focus then return and let android decide + if (nextId == NO_ID) + return null } + return continueGetNextFocus(root, view, direction, nextId, depth) } - enum class FocusDirection { - Left, - Right, - Up, - Down, - } fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) { - //println("Keycode: $keyCode") - //showToast( - // this, - // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", - // Toast.LENGTH_LONG - //) - - // Tested keycodes on remote: - // KeyEvent.KEYCODE_MEDIA_FAST_FORWARD - // KeyEvent.KEYCODE_MEDIA_REWIND - // KeyEvent.KEYCODE_MENU - // KeyEvent.KEYCODE_MEDIA_NEXT - // KeyEvent.KEYCODE_MEDIA_PREVIOUS - // KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE // 149 keycode_numpad 5 when (keyCode) { KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { PlayerEventType.SeekForward } + KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { PlayerEventType.SeekBack } + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> { PlayerEventType.NextEpisode } + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> { PlayerEventType.PrevEpisode } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { PlayerEventType.Pause } + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { PlayerEventType.Play } + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { PlayerEventType.Lock } + KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> { PlayerEventType.ToggleHide } + KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { PlayerEventType.ToggleMute } + KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { PlayerEventType.ShowMirrors } @@ -359,21 +534,27 @@ object CommonActivity { KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { PlayerEventType.SearchSubtitlesOnline } + KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { PlayerEventType.ShowSpeed } + KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { PlayerEventType.Resize } + KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { PlayerEventType.SkipOp } + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { PlayerEventType.SkipCurrentChapter } + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation PlayerEventType.PlayPauseToggle } + else -> null }?.let { playerEvent -> playerEventListener?.invoke(playerEvent) @@ -386,64 +567,64 @@ object CommonActivity { //} } + /** overrides focus and custom key events */ fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? { if (act == null) return null + val currentFocus = act.currentFocus + event?.keyCode?.let { keyCode -> - when (event.action) { - KeyEvent.ACTION_DOWN -> { - if (act.currentFocus != null) { - val next = when (keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Left - ) - KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Right - ) - KeyEvent.KEYCODE_DPAD_UP -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Up - ) - KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Down - ) + if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let + val nextView = when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( + act, + currentFocus, + FocusDirection.Start + ) - else -> null - } + KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus( + act, + currentFocus, + FocusDirection.End + ) - if (next != null && next != -1) { - val nextView = act.findViewById(next) - if (nextView != null) { - nextView.requestFocus() - keyEventListener?.invoke(Pair(event, true)) - return true - } - } + KeyEvent.KEYCODE_DPAD_UP -> getNextFocus( + act, + currentFocus, + FocusDirection.Up + ) - when (keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) { - UIHelper.showInputMethod(act.currentFocus?.findFocus()) - } - } - } - } - //println("Keycode: $keyCode") - //showToast( - // this, - // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", - // Toast.LENGTH_LONG - //) - } + KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus( + act, + currentFocus, + FocusDirection.Down + ) + + else -> null } + // println("NEXT FOCUS : $nextView") + if (nextView != null) { + nextView.requestFocus() + keyEventListener?.invoke(Pair(event, true)) + return true + } + + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && + (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) + ) { + UIHelper.showInputMethod(act.currentFocus?.findFocus()) + } + + //println("Keycode: $keyCode") + //showToast( + // this, + // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", + // Toast.LENGTH_LONG + //) + } + // if someone else want to override the focus then don't handle the event as it is already + // consumed. used in video player if (keyEventListener?.invoke(Pair(event, false)) == true) { return true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt index 379a91e4..8da7ca38 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3 import okhttp3.OkHttpClient import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Response @@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { - private val client: OkHttpClient + private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build() override fun execute(request: Request): Response { val httpMethod: String = request.httpMethod() val url: String = request.url() @@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do val dataToSend: ByteArray? = request.dataToSend() var requestBody: RequestBody? = null if (dataToSend != null) { - requestBody = RequestBody.create(null, dataToSend) + requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size) } val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .method(httpMethod, requestBody).url(url) @@ -50,7 +51,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do companion object { private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" private var instance: DownloaderTestImpl? = null /** @@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do return instance } } - - init { - client = builder.readTimeout(30, TimeUnit.SECONDS).build() - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index eddec15e..5408d2a8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1,21 +1,36 @@ package com.lagradost.cloudstream3 +import android.animation.ValueAnimator import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration +import android.graphics.Rect +import android.net.Uri +import android.os.Build import android.os.Bundle import android.util.AttributeSet import android.util.Log -import android.view.* +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IdRes +import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.marginStart import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController @@ -26,71 +41,119 @@ import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.google.android.gms.cast.framework.* +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSnapHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.Session +import com.google.android.gms.cast.framework.SessionManager +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.navigationrail.NavigationRailView +import com.google.android.material.snackbar.Snackbar +import com.google.common.collect.Comparators.min import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.initAll -import com.lagradost.cloudstream3.APIHolder.updateHasTrailers +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint +import com.lagradost.cloudstream3.CommonActivity.screenHeight +import com.lagradost.cloudstream3.CommonActivity.setActivityInstance import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.updateLocale -import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.CommonActivity.updateTheme +import com.lagradost.cloudstream3.databinding.ActivityMainBinding +import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding +import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver +import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi +import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.home.HomeViewModel +import com.lagradost.cloudstream3.ui.library.LibraryViewModel +import com.lagradost.cloudstream3.ui.player.BasicLink +import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.LinkGenerator +import com.lagradost.cloudstream3.ui.result.LinearListLayout import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST +import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.result.setImage import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.setTextHtml +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.SearchFragment import com.lagradost.cloudstream3.ui.search.SearchResultBuilder -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsGeneral import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable -import com.lagradost.cloudstream3.utils.AppUtils.loadCache -import com.lagradost.cloudstream3.utils.AppUtils.loadRepository -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.ApkInstaller +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable +import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr +import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable +import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl +import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache +import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackupUtils.backup import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching +import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute @@ -99,21 +162,22 @@ import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.nicehttp.Requests -import com.lagradost.nicehttp.ResponseParser -import kotlinx.android.synthetic.main.activity_main.* -import kotlinx.android.synthetic.main.bottom_resultview_preview.* -import kotlinx.android.synthetic.main.fragment_result_swipe.* +import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.USER_PROVIDER_API +import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.utils.fcast.FcastManager +import com.lagradost.safefile.SafeFile import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File +import java.lang.ref.WeakReference import java.net.URI import java.net.URLDecoder import java.nio.charset.Charset -import kotlin.reflect.KClass +import kotlin.math.abs +import kotlin.math.absoluteValue import kotlin.system.exitProcess - //https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898 //https://wiki.videolan.org/Android_Player_Intents/ @@ -124,112 +188,113 @@ import kotlin.system.exitProcess //https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225 -const val VLC_PACKAGE = "org.videolan.vlc" -const val MPV_PACKAGE = "is.xyz.mpv" -const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo" - -val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity") -val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity") - -//TODO REFACTOR AF -open class ResultResume( - val packageString: String, - val action: String = Intent.ACTION_VIEW, - val position: String? = null, - val duration: String? = null, - var launcher: ActivityResultLauncher? = null, -) { - val defaultTime = -1L - - val lastId get() = "${packageString}_last_open_id" - suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) { - val intent = Intent(action) - - if (id != null) - setKey(lastId, id) - else - removeKey(lastId) - - intent.setPackage(packageString) - callback.invoke(intent) - launcher?.launch(intent) - } - - open fun getPosition(intent: Intent?): Long { - return defaultTime - } - - open fun getDuration(intent: Intent?): Long { - return defaultTime - } -} - -val VLC = object : ResultResume( - VLC_PACKAGE, - "org.videolan.vlc.player.result", - "extra_position", - "extra_duration", -) { - override fun getPosition(intent: Intent?): Long { - return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime - } - - override fun getDuration(intent: Intent?): Long { - return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime - } -} - -val MPV = object : ResultResume( - MPV_PACKAGE, - //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive: - position = "position", - duration = "duration", -) { - override fun getPosition(intent: Intent?): Long { - return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() ?: defaultTime - } - - override fun getDuration(intent: Intent?): Long { - return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() ?: defaultTime - } -} - -val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE) - -val resumeApps = arrayOf( - VLC, MPV, WEB_VIDEO -) - -// Short name for requests client to make it nicer to use - -var app = Requests(responseParser = object : ResponseParser { - val mapper: ObjectMapper = jacksonObjectMapper().configure( - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, - false - ) - - override fun parse(text: String, kClass: KClass): T { - return mapper.readValue(text, kClass.java) - } - - override fun parseSafe(text: String, kClass: KClass): T? { - return try { - mapper.readValue(text, kClass.java) - } catch (e: Exception) { - null - } - } - - override fun writeValueAsString(obj: Any): String { - return mapper.writeValueAsString(obj) - } -}).apply { - defaultHeaders = mapOf("user-agent" to USER_AGENT) -} - -class MainActivity : AppCompatActivity(), ColorPickerDialogListener { +class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { + const val VLC_PACKAGE = "org.videolan.vlc" + const val MPV_PACKAGE = "is.xyz.mpv" + const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo" + + val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity") + val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity") + + //TODO REFACTOR AF + open class ResultResume( + val packageString: String, + val action: String = Intent.ACTION_VIEW, + val position: String? = null, + val duration: String? = null, + var launcher: ActivityResultLauncher? = null, + ) { + val defaultTime = -1L + + val lastId get() = "${packageString}_last_open_id" + suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) { + val intent = Intent(action) + + if (id != null) + setKey(lastId, id) + else + removeKey(lastId) + + intent.setPackage(packageString) + callback.invoke(intent) + launcher?.launch(intent) + } + + open fun getPosition(intent: Intent?): Long { + return defaultTime + } + + open fun getDuration(intent: Intent?): Long { + return defaultTime + } + } + + val VLC = object : ResultResume( + VLC_PACKAGE, + // Android 13 intent restrictions fucks up specifically launching the VLC player + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + "org.videolan.vlc.player.result" + } else { + Intent.ACTION_VIEW + }, + "extra_position", + "extra_duration", + ) { + override fun getPosition(intent: Intent?): Long { + return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime + } + + override fun getDuration(intent: Intent?): Long { + return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime + } + } + + val MPV = object : ResultResume( + MPV_PACKAGE, + //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive: + position = "position", + duration = "duration", + ) { + override fun getPosition(intent: Intent?): Long { + return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() + ?: defaultTime + } + + override fun getDuration(intent: Intent?): Long { + return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() + ?: defaultTime + } + } + + val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE) + + val resumeApps = arrayOf( + VLC, MPV, WEB_VIDEO + ) + + const val TAG = "MAINACT" + const val ANIMATED_OUTLINE: Boolean = false + var lastError: String? = null + + private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" + + /** + * Transient files to delete on application exit. + * Deletes files on onDestroy(). + */ + private var filesToDelete: Set + // This needs to be persistent because the application may exit without calling onDestroy. + get() = getKey>(FILE_DELETE_KEY) ?: setOf() + private set(value) = setKey(FILE_DELETE_KEY, value) + + /** + * Add file to delete on Exit. + */ + fun deleteFileOnExit(file: File) { + filesToDelete = filesToDelete + file.path + } /** * Setting this will automatically enter the query in the search @@ -238,7 +303,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { * * This is a very bad solution but I was unable to find a better one. **/ - private var nextSearchQuery: String? = null + var nextSearchQuery: String? = null /** * Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread @@ -254,6 +319,16 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // kinda shitty solution, but cant com main->home otherwise for popups val bookmarksUpdatedEvent = Event() + /** + * Used by DataStoreHelper to fully reload home when switching accounts + */ + val reloadHomeEvent = Event() + + /** + * Used by DataStoreHelper to fully reload library when switching accounts + */ + val reloadLibraryEvent = Event() + /** * @return true if the str has launched an app task (be it successful or not) @@ -265,6 +340,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { isWebview: Boolean ): Boolean = with(activity) { + // TODO MUCH BETTER HANDLING + // Invalid URIs can crash fun safeURI(uri: String) = normalSafeApiCall { URI(uri) } @@ -274,7 +351,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { println("Repository url: $realUrl") loadRepository(realUrl) return true - } else if (str.contains(appString)) { + } else if (str.contains(APP_STRING)) { for (api in OAuth2Apis) { if (str.contains("/${api.redirectUrl}")) { ioSafe { @@ -290,7 +367,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this@with.runOnUiThread { try { showToast( - this@with, getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format( api.name ) @@ -305,20 +381,45 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } // This specific intent is used for the gradle deployWithAdb // https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46 - if (str == "$appString:") { + if (str == "$APP_STRING:") { PluginManager.hotReloadAllLocalPlugins(activity) } - } else if (safeURI(str)?.scheme == appStringRepo) { - val url = str.replaceFirst(appStringRepo, "https") + } else if (safeURI(str)?.scheme == APP_STRING_REPO) { + val url = str.replaceFirst(APP_STRING_REPO, "https") loadRepository(url) return true - } else if (safeURI(str)?.scheme == appStringSearch) { + } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) { + val query = str.substringAfter("$APP_STRING_SEARCH://") nextSearchQuery = - URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8") - nav_view.selectedItemId = R.id.navigation_search - } else if (safeURI(str)?.scheme == appStringResumeWatching) { + try { + URLDecoder.decode(query, "UTF-8") + } catch (t: Throwable) { + logError(t) + query + } + // Use both navigation views to support both layouts. + // It might be better to use the QuickSearch. + activity?.findViewById(R.id.nav_view)?.selectedItemId = + R.id.navigation_search + activity?.findViewById(R.id.nav_rail_view)?.selectedItemId = + R.id.navigation_search + } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { + val uri = Uri.parse(str) + val name = uri.getQueryParameter("name") + val url = URLDecoder.decode(uri.authority, "UTF-8") + + navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + LinkGenerator( + listOf(BasicLink(url, name)), + extract = true, + ) + ) + ) + } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { val id = - str.substringAfter("$appStringResumeWatching://").toIntOrNull() + str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull() ?: return false ioSafe { val resumeWatchingCard = @@ -334,10 +435,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this.navigate(R.id.navigation_downloads) return true } else { - for (api in apis) { - if (str.startsWith(api.mainUrl)) { - loadResult(str, api.name) - return true + synchronized(apis) { + for (api in apis) { + if (str.startsWith(api.mainUrl)) { + loadResult(str, api.name) + return true + } } } } @@ -348,13 +451,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } var lastPopup: SearchResponse? = null - fun loadPopup(result: SearchResponse) { + fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result - viewModel.load( - this, result.url, result.apiName, false, if (getApiDubstatusSettings() - .contains(DubStatus.Dubbed) - ) DubStatus.Dubbed else DubStatus.Subbed, null - ) + val syncName = syncViewModel.syncName(result.apiName) + + // based on apiName we decide on if it is a local list or not, this is because + // we want to show a bit of extra UI to sync apis + if (result is SyncAPI.LibraryItem && syncName != null) { + isLocalList = false + syncViewModel.setSync(syncName, result.syncId) + syncViewModel.updateMetaAndUser() + } else { + isLocalList = true + syncViewModel.clear() + } + + if (load) { + viewModel.load( + this, result.url, result.apiName, false, if (getApiDubstatusSettings() + .contains(DubStatus.Dubbed) + ) DubStatus.Dubbed else DubStatus.Subbed, null + ) + } else { + viewModel.loadSmall(result) + } } override fun onColorSelected(dialogId: Int, color: Int) { @@ -368,6 +488,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) updateLocale() // android fucks me by chaining lang when rotating the phone + updateTheme(this) // Update if system theme val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment @@ -378,7 +499,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this.hideKeyboard() // Fucks up anime info layout since that has its own layout - cast_mini_controller_holder?.isVisible = + binding?.castMiniControllerHolder?.isVisible = !listOf( R.id.navigation_results_phone, R.id.navigation_results_tv, @@ -402,6 +523,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { R.id.navigation_settings_general, R.id.navigation_settings_extensions, R.id.navigation_settings_plugins, + R.id.navigation_test_providers, ).contains(destination.id) @@ -411,17 +533,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { R.id.navigation_results_phone, R.id.navigation_results_tv, R.id.navigation_player, + R.id.navigation_quick_search, ).contains(destination.id) - nav_host_fragment?.apply { + binding?.navHostFragment?.apply { val params = layoutParams as ConstraintLayout.LayoutParams + val push = + if (!dontPush && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0 + + if (!this.isLtr()) { + params.setMargins( + params.leftMargin, + params.topMargin, + push, + params.bottomMargin + ) + } else { + params.setMargins( + push, + params.topMargin, + params.rightMargin, + params.bottomMargin + ) + } - params.setMargins( - if (!dontPush && isTvSettings()) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0, - params.topMargin, - params.rightMargin, - params.bottomMargin - ) layoutParams = params } @@ -429,25 +564,53 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { Configuration.ORIENTATION_LANDSCAPE -> { true } + Configuration.ORIENTATION_PORTRAIT -> { - false + isLayout(TV or EMULATOR) } + else -> { false } } - nav_view?.isVisible = isNavVisible && !landscape - nav_rail_view?.isVisible = isNavVisible && landscape + binding?.apply { + navRailView.isVisible = isNavVisible && landscape + navView.isVisible = isNavVisible && !landscape - // Hide library on TV since it is not supported yet :( - val isTrueTv = isTrueTvSettings() - nav_view?.menu?.findItem(R.id.navigation_library)?.isVisible = !isTrueTv - nav_rail_view?.menu?.findItem(R.id.navigation_library)?.isVisible = !isTrueTv + /** + * We need to make sure if we return to a sub-fragment, + * the correct navigation item is selected so that it does not + * highlight the wrong one in UI. + */ + when (destination.id) { + in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> { + navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true + navView.menu.findItem(R.id.navigation_downloads).isChecked = true + } + in listOf( + R.id.navigation_settings, + R.id.navigation_subtitles, + R.id.navigation_chrome_subtitles, + R.id.navigation_settings_player, + R.id.navigation_settings_updates, + R.id.navigation_settings_ui, + R.id.navigation_settings_account, + R.id.navigation_settings_providers, + R.id.navigation_settings_general, + R.id.navigation_settings_extensions, + R.id.navigation_settings_plugins, + R.id.navigation_test_providers + ) -> { + navRailView.menu.findItem(R.id.navigation_settings).isChecked = true + navView.menu.findItem(R.id.navigation_settings).isChecked = true + } + } + } } //private var mCastSession: CastSession? = null - lateinit var mSessionManager: SessionManager + var mSessionManager: SessionManager? = null private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() } private inner class SessionManagerListenerImpl : SessionManagerListener { @@ -484,10 +647,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::onAllPluginsLoaded + setActivityInstance(this) try { if (isCastApiAvailable()) { - //mCastSession = mSessionManager.currentCastSession - mSessionManager.addSessionManagerListener(mSessionManagerListener) + mSessionManager?.addSessionManagerListener(mSessionManagerListener) } } catch (e: Exception) { logError(e) @@ -503,7 +666,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } try { if (isCastApiAvailable()) { - mSessionManager.removeSessionManagerListener(mSessionManagerListener) + mSessionManager?.removeSessionManagerListener(mSessionManagerListener) //mCastSession = null } } catch (e: Exception) { @@ -511,11 +674,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } - - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - CommonActivity.dispatchKeyEvent(this, event)?.let { - return it - } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val response = CommonActivity.dispatchKeyEvent(this, event) + if (response != null) + return response return super.dispatchKeyEvent(event) } @@ -542,35 +704,16 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { builder.show().setDefaultFocus() } - private fun backPressed() { - this.window?.navigationBarColor = - this.colorFromAttribute(R.attr.primaryGrayBackground) - this.updateLocale() - this.updateLocale() - - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment - val navController = navHostFragment?.navController - val isAtHome = - navController?.currentDestination?.matchDestination(R.id.navigation_home) == true - - if (isAtHome && isTrueTvSettings()) { - showConfirmExitDialog() - } else { - super.onBackPressed() - } - } - - override fun onBackPressed() { - ((supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.childFragmentManager?.primaryNavigationFragment as? IOnBackPressed)?.onBackPressed() - ?.let { runNormal -> - if (runNormal) backPressed() - } ?: run { - backPressed() - } - } - override fun onDestroy() { + filesToDelete.forEach { path -> + val result = File(path).deleteRecursively() + if (result) { + Log.d(TAG, "Deleted temporary file: $path") + } else { + Log.d(TAG, "Failed to delete temporary file: $path") + } + } + filesToDelete = setOf() val broadcastIntent = Intent() broadcastIntent.action = "restart_service" broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) @@ -620,73 +763,381 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { - // Load cloned sites after plugins have been loaded since clones depend on plugins. - try { - getKey>(USER_PROVIDER_API)?.let { list -> - list.forEach { custom -> - allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } - ?.let { - allProviders.add(it.javaClass.newInstance().apply { - name = custom.name - lang = custom.lang - mainUrl = custom.url.trimEnd('/') - canBeOverridden = false - }) - } + synchronized(allProviders) { + // Load cloned sites after plugins have been loaded since clones depend on plugins. + try { + getKey>(USER_PROVIDER_API)?.let { list -> + list.forEach { custom -> + allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } + ?.let { + allProviders.add(it.javaClass.getDeclaredConstructor().newInstance().apply { + name = custom.name + lang = custom.lang + mainUrl = custom.url.trimEnd('/') + canBeOverridden = false + }) + } + } } + // it.hashCode() is not enough to make sure they are distinct + apis = + allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name } + APIHolder.apiMap = null + } catch (e: Exception) { + logError(e) } - // it.hashCode() is not enough to make sure they are distinct - apis = - allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name } - APIHolder.apiMap = null - } catch (e: Exception) { - logError(e) } } } } lateinit var viewModel: ResultViewModel2 + lateinit var syncViewModel: SyncViewModel + private var libraryViewModel: LibraryViewModel? = null + /** kinda dirty, however it signals that we should use the watch status as sync or not*/ + var isLocalList: Boolean = false override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { - viewModel = - ViewModelProvider(this)[ResultViewModel2::class.java] + + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java] return super.onCreateView(name, context, attrs) } private fun hidePreviewPopupDialog() { - viewModel.clear() bottomPreviewPopup.dismissSafe(this) + bottomPreviewPopup = null + bottomPreviewBinding = null } - var bottomPreviewPopup: BottomSheetDialog? = null - private fun showPreviewPopupDialog(): BottomSheetDialog { - val ret = (bottomPreviewPopup ?: run { + private var bottomPreviewPopup: BottomSheetDialog? = null + private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null + private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding { + val ret = (bottomPreviewBinding ?: run { val builder = BottomSheetDialog(this) - builder.setContentView(R.layout.bottom_resultview_preview) + val binding: BottomResultviewPreviewBinding = + BottomResultviewPreviewBinding.inflate(builder.layoutInflater, null, false) + bottomPreviewBinding = binding + builder.setContentView(binding.root) builder.setOnDismissListener { bottomPreviewPopup = null + bottomPreviewBinding = null viewModel.clear() } builder.setCanceledOnTouchOutside(true) builder.show() - builder + bottomPreviewPopup = builder + binding }) - bottomPreviewPopup = ret + return ret } + var binding: ActivityMainBinding? = null + + object TvFocus { + data class FocusTarget( + val width: Int, + val height: Int, + val x: Float, + val y: Float, + ) { + companion object { + fun lerp(a: FocusTarget, b: FocusTarget, lerp: Float): FocusTarget { + val ilerp = 1 - lerp + return FocusTarget( + width = (a.width * ilerp + b.width * lerp).toInt(), + height = (a.height * ilerp + b.height * lerp).toInt(), + x = a.x * ilerp + b.x * lerp, + y = a.y * ilerp + b.y * lerp + ) + } + } + } + + var last: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f) + var current: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f) + + var focusOutline: WeakReference = WeakReference(null) + var lastFocus: WeakReference = WeakReference(null) + private val layoutListener: View.OnLayoutChangeListener = + View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + // shitty fix for layouts + lastFocus.get()?.apply { + updateFocusView( + this, same = true + ) + postDelayed({ + updateFocusView( + lastFocus.get(), same = false + ) + }, 300) + } + } + private val attachListener: View.OnAttachStateChangeListener = + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + updateFocusView(v) + } + + override fun onViewDetachedFromWindow(v: View) { + // removes the focus view but not the listener as updateFocusView(null) will remove the listener + focusOutline.get()?.isVisible = false + } + } + /*private val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + current = current.copy(x = current.x + dx, y = current.y + dy) + setTargetPosition(current) + } + }*/ + + private fun setTargetPosition(target: FocusTarget) { + focusOutline.get()?.apply { + layoutParams = layoutParams?.apply { + width = target.width + height = target.height + } + + translationX = target.x + translationY = target.y + bringToFront() + } + } + + private var animator: ValueAnimator? = null + + /** if this is enabled it will keep the focus unmoving + * during listview move */ + private const val NO_MOVE_LIST: Boolean = false + + /** If this is enabled then it will try to move the + * listview focus to the left instead of center */ + private const val LEFTMOST_MOVE_LIST: Boolean = true + + private val reflectedScroll by lazy { + try { + RecyclerView::class.java.declaredMethods.firstOrNull { + it.name == "scrollStep" + }?.also { it.isAccessible = true } + } catch (t: Throwable) { + null + } + } + + @MainThread + fun updateFocusView(newFocus: View?, same: Boolean = false) { + val focusOutline = focusOutline.get() ?: return + val lastView = lastFocus.get() + val exactlyTheSame = lastView == newFocus && newFocus != null + if (!exactlyTheSame) { + lastView?.removeOnLayoutChangeListener(layoutListener) + lastView?.removeOnAttachStateChangeListener(attachListener) + (lastView?.parent as? RecyclerView)?.apply { + removeOnLayoutChangeListener(layoutListener) + //removeOnScrollListener(scrollListener) + } + } + + val wasGone = focusOutline.isGone + + val visible = + newFocus != null && newFocus.measuredHeight > 0 && newFocus.measuredWidth > 0 && newFocus.isShown && newFocus.tag != "tv_no_focus_tag" + focusOutline.isVisible = visible + + if (newFocus != null) { + lastFocus = WeakReference(newFocus) + val parent = newFocus.parent + var targetDx = 0 + if (parent is RecyclerView) { + val layoutManager = parent.layoutManager + if (layoutManager is LinearListLayout && layoutManager.orientation == LinearLayoutManager.HORIZONTAL) { + val dx = + LinearSnapHelper().calculateDistanceToFinalSnap(layoutManager, newFocus) + ?.get(0) + + if (dx != null) { + val rdx = if (LEFTMOST_MOVE_LIST) { + // this makes the item the leftmost in ltr, instead of center + val diff = + ((layoutManager.width - layoutManager.paddingStart - newFocus.measuredWidth) / 2) - newFocus.marginStart + dx + if (parent.isRtl()) { + -diff + } else { + diff + } + } else { + if (dx > 0) dx else 0 + } + + if (!NO_MOVE_LIST) { + parent.smoothScrollBy(rdx, 0) + } else { + val smoothScroll = reflectedScroll + if (smoothScroll == null) { + parent.smoothScrollBy(rdx, 0) + } else { + try { + // this is very fucked but because it is a protected method to + // be able to compute the scroll I use reflection, scroll, then + // scroll back, then smooth scroll and set the no move + val out = IntArray(2) + smoothScroll.invoke(parent, rdx, 0, out) + val scrolledX = out[0] + if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2 + smoothScroll.invoke(parent, -rdx, 0, out) + parent.smoothScrollBy(scrolledX, 0) + if (NO_MOVE_LIST) targetDx = scrolledX + } + } catch (t: Throwable) { + parent.smoothScrollBy(rdx, 0) + } + } + } + } + } + } + + val out = IntArray(2) + newFocus.getLocationInWindow(out) + val (screenX, screenY) = out + var (x, y) = screenX.toFloat() to screenY.toFloat() + val (currentX, currentY) = focusOutline.translationX to focusOutline.translationY + + if (!newFocus.isLtr()) { + x = x - focusOutline.rootView.width + newFocus.measuredWidth + } + x -= targetDx + + // out of bounds = 0,0 + if (screenX == 0 && screenY == 0) { + focusOutline.isVisible = false + } + if (!exactlyTheSame) { + (newFocus.parent as? RecyclerView)?.apply { + addOnLayoutChangeListener(layoutListener) + //addOnScrollListener(scrollListener) + } + newFocus.addOnLayoutChangeListener(layoutListener) + newFocus.addOnAttachStateChangeListener(attachListener) + } + val start = FocusTarget( + x = currentX, + y = currentY, + width = focusOutline.measuredWidth, + height = focusOutline.measuredHeight + ) + val end = FocusTarget( + x = x, + y = y, + width = newFocus.measuredWidth, + height = newFocus.measuredHeight + ) + + // if they are the same within then snap, aka scrolling + val deltaMinX = min(end.width / 2, 60.toPx) + val deltaMinY = min(end.height / 2, 60.toPx) + if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMinX && (start.y - end.y).absoluteValue < deltaMinY) { + animator?.cancel() + last = start + current = end + setTargetPosition(end) + return + } + + // if running then "reuse" + if (animator?.isRunning == true) { + current = end + return + } else { + animator?.cancel() + } + + + last = start + current = end + + // if previously gone, then tp + if (wasGone) { + setTargetPosition(current) + return + } + + // animate between a and b + animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply { + startDelay = 0 + duration = 200 + addUpdateListener { animation -> + val animatedValue = animation.animatedValue as Float + val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f)) + setTargetPosition(target) + } + start() + } + + // post check + if (!same) { + newFocus.postDelayed({ + updateFocusView(lastFocus.get(), same = true) + }, 200) + } + + /* + + the following is working, but somewhat bad code code + + if (!wasGone) { + (focusOutline.parent as? ViewGroup)?.let { + TransitionManager.endTransitions(it) + TransitionManager.beginDelayedTransition( + it, + TransitionSet().addTransition(ChangeBounds()) + .addTransition(ChangeTransform()) + .setDuration(100) + ) + } + } + + focusOutline.layoutParams = focusOutline.layoutParams?.apply { + width = newFocus.measuredWidth + height = newFocus.measuredHeight + } + focusOutline.translationX = x.toFloat() + focusOutline.translationY = y.toFloat()*/ + } + } + } + + private fun centerView(view: View?) { + if (view == null) return + try { + Log.v(TAG, "centerView: $view") + val r = Rect(0, 0, 0, 0) + view.getDrawingRect(r) + val x = r.centerX() + val y = r.centerY() + val dx = r.width() / 2 //screenWidth / 2 + val dy = screenHeight / 2 + val r2 = Rect(x - dx, y - dy, x + dx, y + dy) + view.requestRectangleOnScreen(r2, false) + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_: Throwable) { + } + } + override fun onCreate(savedInstanceState: Bundle?) { app.initClient(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val errorFile = filesDir.resolve("last_error") - var lastError: String? = null if (errorFile.exists() && errorFile.isFile) { lastError = errorFile.readText(Charset.defaultCharset()) errorFile.delete() + } else { + lastError = null } val settingsForProvider = SettingsJson() @@ -700,30 +1151,131 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { super.onCreate(savedInstanceState) try { if (isCastApiAvailable()) { - mSessionManager = CastContext.getSharedInstance(this).sessionManager + CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager } } - } catch (e: Exception) { - logError(e) + } catch (t: Throwable) { + logError(t) } window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) updateTv() - if (isTvSettings()) { - setContentView(R.layout.activity_main_tv) - } else { - setContentView(R.layout.activity_main) + + // backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting? + normalSafeApiCall { + val appVer = BuildConfig.VERSION_NAME + val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" + if (appVer != lastAppAutoBackup) { + setKey("VERSION_NAME", BuildConfig.VERSION_NAME) + normalSafeApiCall { + backup(this) + } + normalSafeApiCall { + // Recompile oat on new version + PluginManager.deleteAllOatFiles(this) + } + } } - changeStatusBarState(isEmulatorSettings()) + // just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH + binding = try { + if (isLayout(TV or EMULATOR)) { + val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false) + setContentView(newLocalBinding.root) + if (isLayout(TV) && ANIMATED_OUTLINE) { + TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline) + newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener { + TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true) + } + newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> + TvFocus.updateFocusView(newFocus) + } + } else { + newLocalBinding.focusOutline.isVisible = false + } + + if (isLayout(TV)) { + // Put here any button you don't want focusing it to center the view + val exceptionButtons = listOf( + R.id.home_preview_play_btt, + R.id.home_preview_info_btt, + R.id.home_preview_hidden_next_focus, + R.id.home_preview_hidden_prev_focus, + R.id.result_play_movie_button, + R.id.result_play_series_button, + R.id.result_resume_series_button, + R.id.result_play_trailer_button, + R.id.result_bookmark_Button, + R.id.result_favorite_Button, + R.id.result_subscribe_Button, + R.id.result_search_Button, + R.id.result_episodes_show_button, + ) + + newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> + if (exceptionButtons.contains(newFocus?.id)) return@addOnGlobalFocusChangeListener + centerView(newFocus) + } + } + + ActivityMainBinding.bind(newLocalBinding.root) // this may crash + } else { + val newLocalBinding = ActivityMainBinding.inflate(layoutInflater, null, false) + setContentView(newLocalBinding.root) + newLocalBinding + } + } catch (t: Throwable) { + showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG) + null + } + + changeStatusBarState(isLayout(EMULATOR)) + + /** Biometric stuff for users without accounts **/ + val noAccounts = settingsManager.getBoolean( + getString(R.string.skip_startup_account_select_key), + false + ) || accounts.count() <= 1 + + if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) { + if (deviceHasPasswordPinLock(this)) { + startBiometricAuthentication(this, R.string.biometric_authentication_title, false) + + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + + // hide background while authenticating, Sorry moms & dads 🙏 + binding?.navHostFragment?.isInvisible = true + } + } + + // Automatically enable jsdelivr if cant connect to raw.githubusercontent.com + if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) { + main { + if (checkGithubConnectivity()) { + this.setKey(getString(R.string.jsdelivr_proxy_key), false) + } else { + this.setKey(getString(R.string.jsdelivr_proxy_key), true) + showSnackbar( + this@MainActivity, + R.string.jsdelivr_enabled, + Snackbar.LENGTH_LONG, + R.string.revert + ) { setKey(getString(R.string.jsdelivr_proxy_key), false) } + } + } + } + + ioSafe { SafeFile.check(this@MainActivity) } if (PluginManager.checkSafeModeFile()) { normalSafeApiCall { - showToast(this, R.string.safe_mode_file, Toast.LENGTH_LONG) + showToast(R.string.safe_mode_file, Toast.LENGTH_LONG) } } else if (lastError == null) { ioSafe { - getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi -> + DataStoreHelper.currentHomePage?.let { homeApi -> mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi)) } ?: run { mainPluginsLoadedEvent.invoke(false) @@ -740,13 +1292,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { loadAllOnlinePlugins(this@MainActivity) } - //Automatically download not existing plugins - if (settingsManager.getBoolean( + //Automatically download not existing plugins, using mode specified. + val autoDownloadPlugin = AutoDownloadMode.getEnum( + settingsManager.getInt( getString(R.string.auto_download_plugins_key), - false + 0 + ) + ) ?: AutoDownloadMode.Disable + if (autoDownloadPlugin != AutoDownloadMode.Disable) { + PluginManager.downloadNotExistingPluginsAndLoad( + this@MainActivity, + autoDownloadPlugin ) - ) { - PluginManager.downloadNotExistingPluginsAndLoad(this@MainActivity) } } @@ -771,59 +1328,184 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { builder.show().setDefaultFocus() } + + fun setUserData(status: Resource?) { + if (isLocalList) return + bottomPreviewBinding?.apply { + when (status) { + is Resource.Success -> { + resultviewPreviewBookmark.isEnabled = true + resultviewPreviewBookmark.setText(status.value.status.stringRes) + resultviewPreviewBookmark.setIconResource(status.value.status.iconRes) + } + + is Resource.Failure -> { + resultviewPreviewBookmark.isEnabled = false + resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) + resultviewPreviewBookmark.text = status.errorString + } + + else -> { + resultviewPreviewBookmark.isEnabled = false + resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) + resultviewPreviewBookmark.setText(R.string.loading) + } + } + } + } + + fun setWatchStatus(state: WatchType?) { + if (!isLocalList || state == null) return + + bottomPreviewBinding?.resultviewPreviewBookmark?.apply { + setIconResource(state.iconRes) + setText(state.stringRes) + } + } + + fun setSubscribeStatus(state: Boolean?) { + bottomPreviewBinding?.resultviewPreviewSubscribe?.apply { + if (state != null) { + val drawable = if (state) { + R.drawable.ic_baseline_notifications_active_24 + } else { + R.drawable.baseline_notifications_none_24 + } + setImageResource(drawable) + } + isVisible = state != null + + setOnClickListener { + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus + + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + } + } + + observe(viewModel.watchStatus, ::setWatchStatus) + observe(syncViewModel.userData, ::setUserData) + observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus) + observeNullable(viewModel.page) { resource -> if (resource == null) { - bottomPreviewPopup.dismissSafe(this) + hidePreviewPopupDialog() return@observeNullable } when (resource) { is Resource.Failure -> { - showToast(this, R.string.error) + showToast(R.string.error) + viewModel.clear() hidePreviewPopupDialog() } + is Resource.Loading -> { showPreviewPopupDialog().apply { - resultview_preview_loading?.isVisible = true - resultview_preview_result?.isVisible = false - resultview_preview_loading_shimmer?.startShimmer() + resultviewPreviewLoading.isVisible = true + resultviewPreviewResult.isVisible = false + resultviewPreviewLoadingShimmer.startShimmer() } } + is Resource.Success -> { val d = resource.value showPreviewPopupDialog().apply { - resultview_preview_loading?.isVisible = false - resultview_preview_result?.isVisible = true - resultview_preview_loading_shimmer?.stopShimmer() + resultviewPreviewLoading.isVisible = false + resultviewPreviewResult.isVisible = true + resultviewPreviewLoadingShimmer.stopShimmer() - resultview_preview_title?.text = d.title + resultviewPreviewTitle.text = d.title - resultview_preview_meta_type.setText(d.typeText) - resultview_preview_meta_year.setText(d.yearText) - resultview_preview_meta_duration.setText(d.durationText) - resultview_preview_meta_rating.setText(d.ratingText) + resultviewPreviewMetaType.setText(d.typeText) + resultviewPreviewMetaYear.setText(d.yearText) + resultviewPreviewMetaDuration.setText(d.durationText) + resultviewPreviewMetaRating.setText(d.ratingText) - resultview_preview_description?.setText(d.plotText) - resultview_preview_poster?.setImage( + resultviewPreviewDescription.setTextHtml(d.plotText) + resultviewPreviewPoster.setImage( d.posterImage ?: d.posterBackgroundImage ) - resultview_preview_poster?.setOnClickListener { - //viewModel.updateWatchStatus(WatchType.PLANTOWATCH) - val value = viewModel.watchStatus.value ?: WatchType.NONE + setUserData(syncViewModel.userData.value) + setWatchStatus(viewModel.watchStatus.value) + setSubscribeStatus(viewModel.subscribeStatus.value) - this@MainActivity.showBottomDialog( - WatchType.values().map { getString(it.stringRes) }.toList(), - value.ordinal, - this@MainActivity.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - viewModel.updateWatchStatus(WatchType.values()[it]) - bookmarksUpdatedEvent(true) + resultviewPreviewBookmark.setOnClickListener { + //viewModel.updateWatchStatus(WatchType.PLANTOWATCH) + if (isLocalList) { + val value = viewModel.watchStatus.value ?: WatchType.NONE + + this@MainActivity.showBottomDialog( + WatchType.entries.map { getString(it.stringRes) }.toList(), + value.ordinal, + this@MainActivity.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + viewModel.updateWatchStatus( + WatchType.entries[it], + this@MainActivity + ) + } + } else { + val value = + (syncViewModel.userData.value as? Resource.Success)?.value?.status + ?: SyncWatchType.NONE + + this@MainActivity.showBottomDialog( + SyncWatchType.entries.map { getString(it.stringRes) }.toList(), + value.ordinal, + this@MainActivity.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + syncViewModel.setStatus(SyncWatchType.entries[it].internalId) + syncViewModel.publishUserData() + } } } - if (!isTvSettings()) // dont want this clickable on tv layout - resultview_preview_description?.setOnClickListener { view -> + observeNullable(viewModel.favoriteStatus) observeFavoriteStatus@{ isFavorite -> + resultviewPreviewFavorite.isVisible = isFavorite != null + if (isFavorite == null) return@observeFavoriteStatus + + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + + resultviewPreviewFavorite.setImageResource(drawable) + } + + resultviewPreviewFavorite.setOnClickListener { + viewModel.toggleFavoriteStatus(this@MainActivity) { newStatus: Boolean? -> + if (newStatus == null) return@toggleFavoriteStatus + + val message = if (newStatus) { + R.string.favorite_added + } else { + R.string.favorite_removed + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(this@MainActivity) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + + if (isLayout(PHONE)) // dont want this clickable on tv layout + resultviewPreviewDescription.setOnClickListener { view -> view.context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) @@ -833,7 +1515,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } - resultview_preview_more_info?.setOnClickListener { + resultviewPreviewMoreInfo.setOnClickListener { + viewModel.clear() hidePreviewPopupDialog() lastPopup?.let { loadSearchResult(it) @@ -867,6 +1550,26 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { logError(e) } } + + // we need to run this after we init all apis, otherwise currentSyncApi will fuck itself + this@MainActivity.runOnUiThread { + // Change library icon with logo of current api in sync + libraryViewModel = ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java] + libraryViewModel?.currentApiName?.observe(this@MainActivity) { + val syncAPI = libraryViewModel?.currentSyncApi + Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}") + val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) { + R.drawable.library_icon + } else { + syncAPI?.icon ?: R.drawable.library_icon + } + + binding?.apply { + navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon) + navView.menu.findItem(R.id.navigation_library)?.setIcon(icon) + } + } + } } SearchResultBuilder.updateCache(this) @@ -874,7 +1577,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) - apis = allProviders.distinctBy { it } + apis = synchronized(allProviders) { + allProviders.distinctBy { it } + } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -887,12 +1592,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? -> // Intercept search and add a query + updateNavBar(navDestination) if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) { bundle?.apply { this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery) - nextSearchQuery = null } } + + if (isLayout(TV or EMULATOR)) { + if (navDestination.matchDestination(R.id.navigation_home)) { + attachBackPressedCallback { + showConfirmExitDialog() + window?.navigationBarColor = + colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() + } + } else detachBackPressedCallback() + } } //val navController = findNavController(R.id.nav_host_fragment) @@ -905,29 +1621,47 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { .setPopExitAnim(R.anim.nav_pop_exit) .setPopUpTo(navController.graph.startDestination, false) .build()*/ - nav_view?.setupWithNavController(navController) - val nav_rail = findViewById(R.id.nav_rail_view) - nav_rail?.setupWithNavController(navController) - if (isTvSettings()) { - nav_rail?.background?.alpha = 200 - } else { - nav_rail?.background?.alpha = 255 + val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f)) + + binding?.navView?.apply { + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor + setupWithNavController(navController) + setOnItemSelectedListener { item -> + onNavDestinationSelected( + item, + navController + ) + } } - nav_rail?.setOnItemSelectedListener { item -> - onNavDestinationSelected( - item, - navController - ) - } - nav_view?.setOnItemSelectedListener { item -> - onNavDestinationSelected( - item, - navController - ) - } - navController.addOnDestinationChangedListener { _, destination, _ -> - updateNavBar(destination) + + binding?.navRailView?.apply { + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor + setupWithNavController(navController) + if (isLayout(TV or EMULATOR)) { + background?.alpha = 200 + } else { + background?.alpha = 255 + } + + setOnItemSelectedListener { item -> + onNavDestinationSelected( + item, + navController + ) + } + + fun noFocus(view: View) { + view.tag = view.context.getString(R.string.tv_no_focus_tag) + (view as? ViewGroup)?.let { + for (child in it.children) { + noFocus(child) + } + } + } + noFocus(this) } loadCache() @@ -950,17 +1684,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { true }*/ - val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f)) - nav_view?.itemRippleColor = rippleColor - nav_rail?.itemRippleColor = rippleColor - nav_rail?.itemActiveIndicatorColor = rippleColor - nav_view?.itemActiveIndicatorColor = rippleColor if (!checkWrite()) { requestRW() if (checkWrite()) return } - CastButtonFactory.setUpMediaRouteButton(this, media_route_button) + //CastButtonFactory.setUpMediaRouteButton(this, media_route_button) // THIS IS CURRENTLY REMOVED BECAUSE HIGHER VERS OF ANDROID NEEDS A NOTIFICATION //if (!VideoDownloadManager.isMyServiceRunning(this, VideoDownloadKeepAliveService::class.java)) { @@ -1027,14 +1756,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - for (api in allProviders) { - providersAndroidManifestString += "\n" + synchronized(allProviders) { + for (api in allProviders) { + providersAndroidManifestString += "\n" + } } - println(providersAndroidManifestString) } @@ -1044,13 +1774,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { runAutoUpdate() } + FcastManager().init(this, false) + APIRepository.dubStatusActive = getApiDubstatusSettings() try { // this ensures that no unnecessary space is taken loadCache() File(filesDir, "exoplayer").deleteRecursively() // old cache - File(cacheDir, "exoplayer").deleteOnExit() // current cache + deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache } catch (e: Exception) { logError(e) } @@ -1060,6 +1792,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { migrateResumeWatching() } + getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> + DataStoreHelper.currentHomePage = homepage + removeKey(USER_SELECTED_HOMEPAGE_API) + } + try { if (getKey(HAS_DONE_SETUP_KEY, false) != true) { navController.navigate(R.id.navigation_setup_language) @@ -1075,8 +1812,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } catch (e: Exception) { logError(e) - } finally { - setKey(HAS_DONE_SETUP_KEY, true) } // Used to check current focus for TV @@ -1088,5 +1823,42 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // } // } + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() + + // If we don't disable we end up in a loop with default behavior calling + // this callback as well, so we disable it, run default behavior, + // then re-enable this callback so it can be used for next back press. + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + ) } -} + + /** Biometric stuff **/ + override fun onAuthenticationSuccess() { + // make background (nav host fragment) visible again + binding?.navHostFragment?.isInvisible = false + } + + override fun onAuthenticationError() { + finish() + } + + suspend fun checkGithubConnectivity(): Boolean { + return try { + app.get( + "https://raw.githubusercontent.com/recloudstream/.github/master/connectivitycheck", + timeout = 5 + ).text.trim() == "ok" + } catch (t: Throwable) { + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt deleted file mode 100644 index c782b29d..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.base64Decode -import com.lagradost.cloudstream3.utils.* - -open class Acefile : ExtractorApi() { - override val name = "Acefile" - override val mainUrl = "https://acefile.co" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url).document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = getAndUnpack(script.data()) - val id = data.substringAfter("{\"id\":\"").substringBefore("\",") - val key = data.substringAfter("var nfck=\"").substringBefore("\";") - app.get("https://acefile.co/local/$id?key=$key").text.let { - base64Decode( - it.substringAfter("JSON.parse(atob(\"").substringBefore("\"))") - ).let { res -> - sources.add( - ExtractorLink( - name, - name, - res.substringAfter("\"file\":\"").substringBefore("\","), - "$mainUrl/", - Qualities.Unknown.value, - ) - ) - } - } - } - } - return sources - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt deleted file mode 100644 index bc910a7e..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import java.net.URI - -class FileMoon : Filesim() { - override val mainUrl = "https://filemoon.to" - override val name = "FileMoon" -} - -open class Filesim : ExtractorApi() { - override val name = "Filesim" - override val mainUrl = "https://files.im" - override val requiresReferer = false - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - with(app.get(url).document) { - this.select("script").forEach { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = getAndUnpack(script.data()) - val foundData = Regex("""sources:\[(.*?)]""").find(data)?.groupValues?.get(1) ?: return@forEach - val fixedData = foundData.replace("file:", """"file":""") - - parseJson>("[$fixedData]").forEach { - callback.invoke( - ExtractorLink( - name, - name, - it.file, - "$mainUrl/", - Qualities.Unknown.value, - URI(it.file).path.endsWith(".m3u8") - ) - ) - } - } - } - } - } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt deleted file mode 100644 index c28a8900..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.getQualityFromName - -open class Linkbox : ExtractorApi() { - override val name = "Linkbox" - override val mainUrl = "https://www.linkbox.to" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val id = Regex("""(/file/|id=)(\S+)[&/?]""").find(url)?.groupValues?.get(2) - app.get("$mainUrl/api/open/get_url?itemId=$id", referer=url).parsedSafe()?.data?.rList?.map { link -> - callback.invoke( - ExtractorLink( - name, - name, - link.url, - url, - getQualityFromName(link.resolution) - ) - ) - } - } - - data class RList( - @JsonProperty("url") val url: String, - @JsonProperty("resolution") val resolution: String?, - ) - - data class Data( - @JsonProperty("rList") val rList: List?, - ) - - data class Responses( - @JsonProperty("data") val data: Data?, - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt deleted file mode 100644 index 93a280ed..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getAndUnpack - -open class Mp4Upload : ExtractorApi() { - override var name = "Mp4Upload" - override var mainUrl = "https://www.mp4upload.com" - private val srcRegex = Regex("""player\.src\("(.*?)"""") - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List? { - with(app.get(url)) { - getAndUnpack(this.text).let { unpackedText -> - val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull() - srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link -> - return listOf( - ExtractorLink( - name, - name, - link, - url, - quality ?: Qualities.Unknown.value, - ) - ) - } - } - } - return null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt deleted file mode 100644 index 70e87fbf..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - -data class DataOptionsJson ( - @JsonProperty("flashvars") var flashvars : Flashvars? = Flashvars(), -) -data class Flashvars ( - @JsonProperty("metadata") var metadata : String? = null, - @JsonProperty("hlsManifestUrl") var hlsManifestUrl : String? = null, //m3u8 -) - -data class MetadataOkru ( - @JsonProperty("videos") var videos: ArrayList = arrayListOf(), -) - -data class Videos ( - @JsonProperty("name") var name : String, - @JsonProperty("url") var url : String, - @JsonProperty("seekSchema") var seekSchema : Int? = null, - @JsonProperty("disallowed") var disallowed : Boolean? = null -) - -class OkRuHttps: OkRu(){ - override var mainUrl = "https://ok.ru" -} - -open class OkRu : ExtractorApi() { - override var name = "Okru" - override var mainUrl = "http://ok.ru" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val doc = app.get(url).document - val sources = ArrayList() - val datajson = doc.select("div[data-options]").attr("data-options") - if (datajson.isNotBlank()) { - val main = parseJson(datajson) - val metadatajson = parseJson(main.flashvars?.metadata!!) - val servers = metadatajson.videos - servers.forEach { - val quality = it.name.uppercase() - .replace("MOBILE","144p") - .replace("LOWEST","240p") - .replace("LOW","360p") - .replace("SD","480p") - .replace("HD","720p") - .replace("FULL","1080p") - .replace("QUAD","1440p") - .replace("ULTRA","4k") - val extractedurl = it.url.replace("\\\\u0026", "&") - sources.add(ExtractorLink( - name, - name = this.name, - extractedurl, - url, - getQualityFromName(quality), - isM3u8 = false - )) - } - } - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt deleted file mode 100644 index a27bf188..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import kotlinx.coroutines.delay -import java.net.URI - -class VidSrcExtractor2 : VidSrcExtractor() { - override val mainUrl = "https://vidsrc.me/embed" - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val newUrl = url.lowercase().replace(mainUrl, super.mainUrl) - super.getUrl(newUrl, referer, subtitleCallback, callback) - } -} - -open class VidSrcExtractor : ExtractorApi() { - override val name = "VidSrc" - private val absoluteUrl = "https://v2.vidsrc.me" - override val mainUrl = "$absoluteUrl/embed" - override val requiresReferer = false - - companion object { - /** Infinite function to validate the vidSrc pass */ - suspend fun validatePass(url: String) { - val uri = URI(url) - val host = uri.host - - // Basically turn https://tm3p.vidsrc.stream/ -> https://vidsrc.stream/ - val referer = host.split(".").let { - val size = it.size - "https://" + it.subList(maxOf(0, size - 2), size).joinToString(".") + "/" - } - - while (true) { - app.get(url, referer = referer) - delay(60_000) - } - } - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val iframedoc = app.get(url).document - - val serverslist = - iframedoc.select("div#sources.button_content div#content div#list div").map { - val datahash = it.attr("data-hash") - if (datahash.isNotBlank()) { - val links = try { - app.get( - "$absoluteUrl/srcrcp/$datahash", - referer = "https://rcp.vidsrc.me/" - ).url - } catch (e: Exception) { - "" - } - links - } else "" - } - - serverslist.amap { server -> - val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/") - if (linkfixed.contains("/prorcp")) { - val srcresponse = app.get(server, referer = absoluteUrl).text - val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)") - val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@amap - val passRegex = Regex("""['"](.*set_pass[^"']*)""") - val pass = passRegex.find(srcresponse)?.groupValues?.get(1)?.replace( - Regex("""^//"""), "https://" - ) - - callback.invoke( - ExtractorLink( - this.name, - this.name, - srcm3u8, - "https://vidsrc.stream/", - Qualities.Unknown.value, - extractorData = pass, - isM3u8 = true - ) - ) - } else { - loadExtractor(linkfixed, url, subtitleCallback, callback) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt deleted file mode 100644 index 12a76a9b..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class Voe : ExtractorApi() { - override val name = "Voe" - override val mainUrl = "https://voe.sx" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val res = app.get(url, referer = referer).document - val link = res.select("script").find { it.data().contains("const sources") }?.data() - ?.substringAfter("\"hls\": \"")?.substringBefore("\",") - - M3u8Helper.generateM3u8( - name, - link ?: return, - "$mainUrl/", - headers = mapOf("Origin" to "$mainUrl/") - ).forEach(callback) - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt deleted file mode 100644 index ad3f0150..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.getQualityFromName - -open class VoeExtractor : ExtractorApi() { - override val name: String = "Voe" - override val mainUrl: String = "https://voe.sx" - override val requiresReferer = false - - private data class ResponseLinks( - @JsonProperty("hls") val hls: String?, - @JsonProperty("mp4") val mp4: String?, - @JsonProperty("video_height") val label: Int? - //val type: String // Mp4 - ) - - override suspend fun getUrl(url: String, referer: String?): List { - val html = app.get(url).text - if (html.isNotBlank()) { - val src = html.substringAfter("const sources =").substringBefore(";") - // Remove last comma, it is not proper json otherwise - .replace("0,", "0") - // Make json use the proper quotes - .replace("'", "\"") - - //Log.i(this.name, "Result => (src) ${src}") - parseJson(src)?.let { voeLink -> - //Log.i(this.name, "Result => (voeLink) ${voeLink}") - - // Always defaults to the hls link, but returns the mp4 if null - val linkUrl = voeLink.hls ?: voeLink.mp4 - val linkLabel = voeLink.label?.toString() ?: "" - if (!linkUrl.isNullOrEmpty()) { - return listOf( - ExtractorLink( - name = this.name, - source = this.name, - url = linkUrl, - quality = getQualityFromName(linkLabel), - referer = url, - isM3u8 = voeLink.hls != null - ) - ) - } - } - } - return emptyList() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt index 07aa904e..5bbb4538 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt @@ -21,10 +21,11 @@ class CrossTmdbProvider : TmdbProvider() { return Regex("""[^a-zA-Z0-9-]""").replace(name, "") } - private val validApis by lazy { - apis.filter { it.lang == this.lang && it::class.java != this::class.java } - //.distinctBy { it.uniqueId } - } + private val validApis + get() = + synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } } + //.distinctBy { it.uniqueId } + data class CrossMetaData( @JsonProperty("isSuccess") val isSuccess: Boolean, @@ -60,7 +61,8 @@ class CrossTmdbProvider : TmdbProvider() { override suspend fun load(url: String): LoadResponse? { val base = super.load(url)?.apply { - this.recommendations = this.recommendations?.filterIsInstance() // TODO REMOVE + this.recommendations = + this.recommendations?.filterIsInstance() // TODO REMOVE val matchName = filterName(this.name) when (this) { is MovieLoadResponse -> { @@ -98,6 +100,7 @@ class CrossTmdbProvider : TmdbProvider() { this.dataUrl = CrossMetaData(true, data.map { it.apiName to it.dataUrl }).toJson() } + else -> { throw ErrorLoadingException("Nothing besides movies are implemented for this provider") } diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt deleted file mode 100644 index e8ac1876..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.lagradost.cloudstream3.metaproviders - -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId -import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi -import com.lagradost.cloudstream3.syncproviders.SyncAPI -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi -import com.lagradost.cloudstream3.syncproviders.providers.MALApi -import com.lagradost.cloudstream3.utils.SyncUtil - -// wont be implemented -class MultiAnimeProvider : MainAPI() { - override var name = "MultiAnime" - override var lang = "en" - override val usesWebView = true - override val supportedTypes = setOf(TvType.Anime) - private val syncApi: SyncAPI = aniListApi - - private val syncUtilType by lazy { - when (syncApi) { - is AniListApi -> "anilist" - is MALApi -> "myanimelist" - else -> throw ErrorLoadingException("Invalid Api") - } - } - - private val validApis by lazy { - APIHolder.apis.filter { - it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains( - TvType.Anime - ) - } - } - - private fun filterName(name: String): String { - return Regex("""[^a-zA-Z0-9-]""").replace(name, "") - } - - override suspend fun search(query: String): List? { - return syncApi.search(query)?.map { - AnimeSearchResponse(it.name, it.url, this.name, TvType.Anime, it.posterUrl) - } - } - - override suspend fun load(url: String): LoadResponse? { - return syncApi.getResult(url)?.let { res -> - val data = SyncUtil.getUrlsFromId(res.id, syncUtilType).amap { url -> - validApis.firstOrNull { api -> url.startsWith(api.mainUrl) }?.load(url) - }.filterNotNull() - - val type = - if (data.any { it.type == TvType.AnimeMovie }) TvType.AnimeMovie else TvType.Anime - - newAnimeLoadResponse( - res.title ?: throw ErrorLoadingException("No Title found"), - url, - type - ) { - posterUrl = res.posterUrl - plot = res.synopsis - tags = res.genres - rating = res.publicScore - addTrailer(res.trailers) - addAniListId(res.id.toIntOrNull()) - recommendations = res.recommendations - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt index 75e96bec..bc646a8d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt @@ -2,15 +2,13 @@ package com.lagradost.cloudstream3.metaproviders import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis import com.lagradost.cloudstream3.syncproviders.SyncIdName object SyncRedirector { - val syncApis = SyncApis private val syncIds = listOf( - SyncIdName.MyAnimeList to Regex("""myanimelist\.net\/anime\/(\d+)"""), - SyncIdName.Anilist to Regex("""anilist\.co\/anime\/(\d+)""") + SyncIdName.MyAnimeList to Regex("""myanimelist\.net/anime/(\d+)"""), + SyncIdName.Anilist to Regex("""anilist\.co/anime/(\d+)""") ) suspend fun redirect( diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt index 314177af..c5b4d453 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt @@ -105,6 +105,7 @@ open class TmdbProvider : MainAPI() { this.id, episode.episode_number, episode.season_number, + this.name ?: this.original_name, ).toJson(), episode.name, episode.season_number, @@ -122,6 +123,7 @@ open class TmdbProvider : MainAPI() { this.id, episodeNum, season.season_number, + this.name ?: this.original_name, ).toJson(), season = season.season_number ) @@ -151,6 +153,8 @@ open class TmdbProvider : MainAPI() { recommendations = (this@toLoadResponse.recommendations ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } addActors(credits?.cast?.toList().toActors()) + + contentRating = fetchContentRating(id, "US") } } @@ -193,6 +197,8 @@ open class TmdbProvider : MainAPI() { recommendations = (this@toLoadResponse.recommendations ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } addActors(credits?.cast?.toList().toActors()) + + contentRating = fetchContentRating(id, "US") } } @@ -264,6 +270,26 @@ open class TmdbProvider : MainAPI() { return null } + open suspend fun fetchContentRating(id: Int?, country: String): String? { + id ?: return null + + val contentRatings = tmdb.tvService().content_ratings(id).awaitResponse().body()?.results + return if (!contentRatings.isNullOrEmpty()) { + contentRatings.firstOrNull { it: ContentRating -> + it.iso_3166_1 == country + }?.rating + } else { + val releaseDates = tmdb.moviesService().releaseDates(id).awaitResponse().body()?.results + val certification = releaseDates?.firstOrNull { it: ReleaseDatesResult -> + it.iso_3166_1 == country + }?.release_dates?.firstOrNull { it: ReleaseDate -> + !it.certification.isNullOrBlank() + }?.certification + + certification + } + } + // Possible to add recommendations and such here. override suspend fun load(url: String): LoadResponse? { // https://www.themoviedb.org/movie/7445-brothers diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt new file mode 100644 index 00000000..addee9a0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -0,0 +1,471 @@ +package com.lagradost.cloudstream3.metaproviders + +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.Actor +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId +import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.ProviderType +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.addDate +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.base64Decode +import com.lagradost.cloudstream3.mainPageOf +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.newHomePageResponse +import com.lagradost.cloudstream3.newMovieLoadResponse +import com.lagradost.cloudstream3.newMovieSearchResponse +import com.lagradost.cloudstream3.newTvSeriesLoadResponse +import com.lagradost.cloudstream3.newTvSeriesSearchResponse +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.math.roundToInt + +open class TraktProvider : MainAPI() { + override var name = "Trakt" + override val hasMainPage = true + override val providerType = ProviderType.MetaProvider + override val supportedTypes = setOf( + TvType.Movie, + TvType.TvSeries, + TvType.Anime, + ) + + private val traktClientId = + base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==") + private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2") + + override val mainPage = mainPageOf( + "$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now + "$traktApiUrl/movies/popular" to "Popular Movies", //The most popular movies for all time + "$traktApiUrl/shows/trending" to "Trending Shows", //Most watched Shows right now + "$traktApiUrl/shows/popular" to "Popular Shows", //The most popular Shows for all time + ) + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + + val apiResponse = getApi("${request.data}?extended=cloud9,full&page=$page") + + val results = parseJson>(apiResponse).map { element -> + element.toSearchResponse() + } + return newHomePageResponse(request.name, results) + } + + private fun MediaDetails.toSearchResponse(): SearchResponse { + + val media = this.media ?: this + val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries + val poster = media.images?.poster?.firstOrNull() + + if (mediaType == TvType.Movie) { + return newMovieSearchResponse( + name = media.title!!, + url = Data( + type = mediaType, + mediaDetails = media, + ).toJson(), + type = TvType.Movie, + ) { + posterUrl = fixPath(poster) + } + } else { + return newTvSeriesSearchResponse( + name = media.title!!, + url = Data( + type = mediaType, + mediaDetails = media, + ).toJson(), + type = TvType.TvSeries, + ) { + this.posterUrl = fixPath(poster) + } + } + } + + override suspend fun search(query: String): List? { + val apiResponse = + getApi("$traktApiUrl/search/movie,show?extended=cloud9,full&limit=20&page=1&query=$query") + + val results = parseJson>(apiResponse).map { element -> + element.toSearchResponse() + } + + return results + } + + override suspend fun load(url: String): LoadResponse { + + val data = parseJson(url) + val mediaDetails = data.mediaDetails + val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows" + + val posterUrl = mediaDetails?.images?.poster?.firstOrNull() + val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull() + + val resActor = + getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full") + + val actors = parseJson(resActor).cast?.map { + ActorData( + Actor( + name = it.person?.name!!, + image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500") + ), + roleString = it.character + ) + } + + val resRelated = + getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20") + + val relatedMedia = parseJson>(resRelated).map { it.toSearchResponse() } + + val isCartoon = + mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true + val isAnime = + isCartoon && (mediaDetails?.language == "zh" || mediaDetails?.language == "ja") + val isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko") + val isBollywood = mediaDetails?.country == "in" + + if (data.type == TvType.Movie) { + + val linkData = LinkData( + id = mediaDetails?.ids?.tmdb, + traktId = mediaDetails?.ids?.trakt, + traktSlug = mediaDetails?.ids?.slug, + tmdbId = mediaDetails?.ids?.tmdb, + imdbId = mediaDetails?.ids?.imdb.toString(), + tvdbId = mediaDetails?.ids?.tvdb, + tvrageId = mediaDetails?.ids?.tvrage, + type = data.type.toString(), + title = mediaDetails?.title, + year = mediaDetails?.year, + orgTitle = mediaDetails?.title, + isAnime = isAnime, + //jpTitle = later if needed as it requires another network request, + airedDate = mediaDetails?.released + ?: mediaDetails?.firstAired, + isAsian = isAsian, + isBollywood = isBollywood, + ).toJson() + + return newMovieLoadResponse( + name = mediaDetails?.title!!, + url = data.toJson(), + dataUrl = linkData.toJson(), + type = if (isAnime) TvType.AnimeMovie else TvType.Movie, + ) { + this.name = mediaDetails.title + this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie + this.posterUrl = getOriginalWidthImageUrl(posterUrl) + this.year = mediaDetails.year + this.plot = mediaDetails.overview + this.rating = mediaDetails.rating?.times(1000)?.roundToInt() + this.tags = mediaDetails.genres + this.duration = mediaDetails.runtime + this.recommendations = relatedMedia + this.actors = actors + this.comingSoon = isUpcoming(mediaDetails.released) + //posterHeaders + this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl) + this.contentRating = mediaDetails.certification + addTrailer(mediaDetails.trailer) + addImdbId(mediaDetails.ids?.imdb) + addTMDbId(mediaDetails.ids?.tmdb.toString()) + } + } else { + + val resSeasons = + getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes") + val episodes = mutableListOf() + val seasons = parseJson>(resSeasons) + var nextAir: NextAiring? = null + + seasons.forEach { season -> + + season.episodes?.map { episode -> + + val linkData = LinkData( + id = mediaDetails?.ids?.tmdb, + traktId = mediaDetails?.ids?.trakt, + traktSlug = mediaDetails?.ids?.slug, + tmdbId = mediaDetails?.ids?.tmdb, + imdbId = mediaDetails?.ids?.imdb.toString(), + tvdbId = mediaDetails?.ids?.tvdb, + tvrageId = mediaDetails?.ids?.tvrage, + type = data.type.toString(), + season = episode.season, + episode = episode.number, + title = mediaDetails?.title, + year = mediaDetails?.year, + orgTitle = mediaDetails?.title, + isAnime = isAnime, + airedYear = mediaDetails?.year, + lastSeason = seasons.size, + epsTitle = episode.title, + //jpTitle = later if needed as it requires another network request, + date = episode.firstAired, + airedDate = episode.firstAired, + isAsian = isAsian, + isBollywood = isBollywood, + isCartoon = isCartoon + ).toJson() + + episodes.add( + Episode( + data = linkData.toJson(), + name = episode.title, + season = episode.season, + episode = episode.number, + posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()), + rating = episode.rating?.times(10)?.roundToInt(), + description = episode.overview, + runTime = episode.runtime + ).apply { + this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) { + nextAir = NextAiring( + episode = this.episode!!, + unixTime = this.date!!.div(1000L), + season = if (this.season == 1) null else this.season, + ) + } + } + ) + } + } + + return newTvSeriesLoadResponse( + name = mediaDetails?.title!!, + url = data.toJson(), + type = if (isAnime) TvType.Anime else TvType.TvSeries, + episodes = episodes + ) { + this.name = mediaDetails.title + this.type = if (isAnime) TvType.Anime else TvType.TvSeries + this.episodes = episodes + this.posterUrl = getOriginalWidthImageUrl(posterUrl) + this.year = mediaDetails.year + this.plot = mediaDetails.overview + this.showStatus = getStatus(mediaDetails.status) + this.rating = mediaDetails.rating?.times(1000)?.roundToInt() + this.tags = mediaDetails.genres + this.duration = mediaDetails.runtime + this.recommendations = relatedMedia + this.actors = actors + this.comingSoon = isUpcoming(mediaDetails.released) + //posterHeaders + this.nextAiring = nextAir + this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl) + this.contentRating = mediaDetails.certification + addTrailer(mediaDetails.trailer) + addImdbId(mediaDetails.ids?.imdb) + addTMDbId(mediaDetails.ids?.tmdb.toString()) + } + } + } + + private suspend fun getApi(url: String): String { + return app.get( + url = url, + headers = mapOf( + "Content-Type" to "application/json", + "trakt-api-version" to "2", + "trakt-api-key" to traktClientId, + ) + ).toString() + } + + private fun isUpcoming(dateString: String?): Boolean { + return try { + val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val dateTime = dateString?.let { format.parse(it)?.time } ?: return false + unixTimeMS < dateTime + } catch (t: Throwable) { + logError(t) + false + } + } + + private fun getStatus(t: String?): ShowStatus { + return when (t) { + "returning series" -> ShowStatus.Ongoing + "continuing" -> ShowStatus.Ongoing + else -> ShowStatus.Completed + } + } + + private fun fixPath(url: String?): String? { + url ?: return null + return "https://$url" + } + + private fun getWidthImageUrl(path: String?, width: String): String? { + if (path == null) return null + if (!path.contains("image.tmdb.org")) return fixPath(path) + val fileName = Uri.parse(path).lastPathSegment ?: return null + return "https://image.tmdb.org/t/p/${width}/${fileName}" + } + + private fun getOriginalWidthImageUrl(path: String?): String? { + if (path == null) return null + if (!path.contains("image.tmdb.org")) return fixPath(path) + return getWidthImageUrl(path, "original") + } + + data class Data( + val type: TvType? = null, + val mediaDetails: MediaDetails? = null, + ) + + data class MediaDetails( + @JsonProperty("title") val title: String? = null, + @JsonProperty("year") val year: Int? = null, + @JsonProperty("ids") val ids: Ids? = null, + @JsonProperty("tagline") val tagline: String? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("released") val released: String? = null, + @JsonProperty("runtime") val runtime: Int? = null, + @JsonProperty("country") val country: String? = null, + @JsonProperty("updatedAt") val updatedAt: String? = null, + @JsonProperty("trailer") val trailer: String? = null, + @JsonProperty("homepage") val homepage: String? = null, + @JsonProperty("status") val status: String? = null, + @JsonProperty("rating") val rating: Double? = null, + @JsonProperty("votes") val votes: Long? = null, + @JsonProperty("comment_count") val commentCount: Long? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("languages") val languages: List? = null, + @JsonProperty("available_translations") val availableTranslations: List? = null, + @JsonProperty("genres") val genres: List? = null, + @JsonProperty("certification") val certification: String? = null, + @JsonProperty("aired_episodes") val airedEpisodes: Int? = null, + @JsonProperty("first_aired") val firstAired: String? = null, + @JsonProperty("airs") val airs: Airs? = null, + @JsonProperty("network") val network: String? = null, + @JsonProperty("images") val images: Images? = null, + @JsonProperty("movie") @JsonAlias("show") val media: MediaDetails? = null + ) + + data class Airs( + @JsonProperty("day") val day: String? = null, + @JsonProperty("time") val time: String? = null, + @JsonProperty("timezone") val timezone: String? = null, + ) + + data class Ids( + @JsonProperty("trakt") val trakt: Int? = null, + @JsonProperty("slug") val slug: String? = null, + @JsonProperty("tvdb") val tvdb: Int? = null, + @JsonProperty("imdb") val imdb: String? = null, + @JsonProperty("tmdb") val tmdb: Int? = null, + @JsonProperty("tvrage") val tvrage: String? = null, + ) + + data class Images( + @JsonProperty("fanart") val fanart: List? = null, + @JsonProperty("poster") val poster: List? = null, + @JsonProperty("logo") val logo: List? = null, + @JsonProperty("clearart") val clearart: List? = null, + @JsonProperty("banner") val banner: List? = null, + @JsonProperty("thumb") val thumb: List? = null, + @JsonProperty("screenshot") val screenshot: List? = null, + @JsonProperty("headshot") val headshot: List? = null, + ) + + data class People( + @JsonProperty("cast") val cast: List? = null, + ) + + data class Cast( + @JsonProperty("character") val character: String? = null, + @JsonProperty("characters") val characters: List? = null, + @JsonProperty("episode_count") val episodeCount: Long? = null, + @JsonProperty("person") val person: Person? = null, + @JsonProperty("images") val images: Images? = null, + ) + + data class Person( + @JsonProperty("name") val name: String? = null, + @JsonProperty("ids") val ids: Ids? = null, + @JsonProperty("images") val images: Images? = null, + ) + + data class Seasons( + @JsonProperty("aired_episodes") val airedEpisodes: Int? = null, + @JsonProperty("episode_count") val episodeCount: Int? = null, + @JsonProperty("episodes") val episodes: List? = null, + @JsonProperty("first_aired") val firstAired: String? = null, + @JsonProperty("ids") val ids: Ids? = null, + @JsonProperty("images") val images: Images? = null, + @JsonProperty("network") val network: String? = null, + @JsonProperty("number") val number: Int? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("rating") val rating: Double? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("updated_at") val updatedAt: String? = null, + @JsonProperty("votes") val votes: Int? = null, + ) + + data class TraktEpisode( + @JsonProperty("available_translations") val availableTranslations: List? = null, + @JsonProperty("comment_count") val commentCount: Int? = null, + @JsonProperty("episode_type") val episodeType: String? = null, + @JsonProperty("first_aired") val firstAired: String? = null, + @JsonProperty("ids") val ids: Ids? = null, + @JsonProperty("images") val images: Images? = null, + @JsonProperty("number") val number: Int? = null, + @JsonProperty("number_abs") val numberAbs: Int? = null, + @JsonProperty("overview") val overview: String? = null, + @JsonProperty("rating") val rating: Double? = null, + @JsonProperty("runtime") val runtime: Int? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("title") val title: String? = null, + @JsonProperty("updated_at") val updatedAt: String? = null, + @JsonProperty("votes") val votes: Int? = null, + ) + + data class LinkData( + val id: Int? = null, + val traktId: Int? = null, + val traktSlug: String? = null, + val tmdbId: Int? = null, + val imdbId: String? = null, + val tvdbId: Int? = null, + val tvrageId: String? = null, + val type: String? = null, + val season: Int? = null, + val episode: Int? = null, + val aniId: String? = null, + val animeId: String? = null, + val title: String? = null, + val year: Int? = null, + val orgTitle: String? = null, + val isAnime: Boolean = false, + val airedYear: Int? = null, + val lastSeason: Int? = null, + val epsTitle: String? = null, + val jpTitle: String? = null, + val date: String? = null, + val airedDate: String? = null, + val isAsian: Boolean = false, + val isBollywood: Boolean = false, + val isCartoon: Boolean = false, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt new file mode 100644 index 00000000..3df5197c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt @@ -0,0 +1,16 @@ +package com.lagradost.cloudstream3.mvvm + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData + +/** NOTE: Only one observer at a time per value */ +fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { + liveData.removeObservers(this) + liveData.observe(this) { it?.let { t -> action(t) } } +} + +/** NOTE: Only one observer at a time per value */ +fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { + liveData.removeObservers(this) + liveData.observe(this) { action(it) } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt index 6950d961..85a9db5d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -9,7 +9,10 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking -import okhttp3.* +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response import java.net.URI @@ -17,6 +20,8 @@ import java.net.URI class CloudflareKiller : Interceptor { companion object { const val TAG = "CloudflareKiller" + private val ERROR_CODES = listOf(403, 503) + private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare") fun parseCookieMap(cookie: String): Map { return cookie.split(";").associate { val split = it.split("=") @@ -48,15 +53,23 @@ class CloudflareKiller : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() - val cookies = savedCookies[request.url.host] - if (cookies == null) { - bypassCloudflare(request)?.let { - Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") - return@runBlocking it + when (val cookies = savedCookies[request.url.host]) { + null -> { + val response = chain.proceed(request) + if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) { + return@runBlocking response + } else { + response.close() + bypassCloudflare(request)?.let { + Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") + return@runBlocking it + } + } + } + else -> { + return@runBlocking proceed(request, cookies) } - } else { - return@runBlocking proceed(request, cookies) } debugWarning({ true }) { "Failed cloudflare at: ${request.url}" } diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt index e89ccfeb..ddf5b286 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt @@ -2,5 +2,4 @@ package com.lagradost.cloudstream3.plugins @Suppress("unused") @Target(AnnotationTarget.CLASS) -annotation class CloudstreamPlugin( -) \ No newline at end of file +annotation class CloudstreamPlugin \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt index 242baf59..fc836587 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -34,9 +34,11 @@ abstract class Plugin { */ fun registerMainAPI(element: MainAPI) { Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") - element.sourcePlugin = this.__filename + element.sourcePlugin = this.filename // Race condition causing which would case duplicates if not for distinctBy - APIHolder.allProviders.add(element) + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.add(element) + } APIHolder.addPluginMapping(element) } @@ -46,22 +48,31 @@ abstract class Plugin { */ fun registerExtractorAPI(element: ExtractorApi) { Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") - element.sourcePlugin = this.__filename + element.sourcePlugin = this.filename extractorApis.add(element) } class Manifest { - @JsonProperty("name") var name: String? = null - @JsonProperty("pluginClassName") var pluginClassName: String? = null - @JsonProperty("version") var version: Int? = null - @JsonProperty("requiresResources") var requiresResources: Boolean = false + @JsonProperty("name") + var name: String? = null + @JsonProperty("pluginClassName") + var pluginClassName: String? = null + @JsonProperty("version") + var version: Int? = null + @JsonProperty("requiresResources") + var requiresResources: Boolean = false } /** * This will contain your resources if you specified requiresResources in gradle */ var resources: Resources? = null - var __filename: String? = null + /** Full file path to the plugin. */ + @Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename")) + var __filename: String? + get() = filename + set(value) {filename = value} + var filename: String? = null /** * This will add a button in the settings allowing you to add custom settings diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 3533d6a8..bc2a1780 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -1,23 +1,25 @@ package com.lagradost.cloudstream3.plugins +import android.Manifest import android.app.* import android.content.Context +import android.content.pm.PackageManager import android.content.res.AssetManager import android.content.res.Resources import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.google.gson.Gson import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.removePluginMapping +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider @@ -33,6 +35,7 @@ import com.lagradost.cloudstream3.ui.result.UiText import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute @@ -136,6 +139,20 @@ object PluginManager { } } + /** + * Deletes all generated oat files which will force Android to recompile the dex extensions. + * This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update. + */ + fun deleteAllOatFiles(context: Context) { + File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo -> + repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file -> + val success = file.deleteRecursively() + Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success") + } + } + } + + fun getPluginsOnline(): Array { return getKey(PLUGINS_KEY) ?: emptyArray() } @@ -149,7 +166,7 @@ object PluginManager { private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins" - public var currentlyLoading: String? = null + var currentlyLoading: String? = null // Maps filepath to plugin val plugins: MutableMap = @@ -162,14 +179,18 @@ object PluginManager { private val classLoaders: MutableMap = HashMap() - private var loadedLocalPlugins = false + var loadedLocalPlugins = false + private set + + var loadedOnlinePlugins = false + private set private val gson = Gson() - private suspend fun maybeLoadPlugin(activity: Activity, file: File) { + private suspend fun maybeLoadPlugin(context: Context, file: File) { val name = file.name if (file.extension == "zip" || file.extension == "cs3") { loadPlugin( - activity, + context, file, PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET) ) @@ -199,7 +220,7 @@ object PluginManager { // var allCurrentOutDatedPlugins: Set = emptySet() - suspend fun loadSinglePlugin(activity: Activity, apiName: String): Boolean { + suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean { return (getPluginsOnline().firstOrNull { // Most of the time the provider ends with Provider which isn't part of the api name it.internalName.replace("provider", "", ignoreCase = true) == apiName @@ -209,7 +230,7 @@ object PluginManager { })?.let { savedData -> // OnlinePluginData(savedData, onlineData) loadPlugin( - activity, + context, File(savedData.filePath), savedData ) @@ -276,6 +297,7 @@ object PluginManager { } // ioSafe { + loadedOnlinePlugins = true afterPluginsLoadedEvent.invoke(false) // } @@ -288,7 +310,7 @@ object PluginManager { * 2. Fetch all not downloaded plugins * 3. Download them and reload plugins **/ - fun downloadNotExistingPluginsAndLoad(activity: Activity) { + fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) { val newDownloadPlugins = mutableListOf() val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES @@ -302,6 +324,8 @@ object PluginManager { // Iterate online repos and returns not downloaded plugins val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> val sitePlugin = onlineData.second + val tvtypes = sitePlugin.tvTypes ?: listOf() + //Don't include empty urls if (sitePlugin.url.isBlank()) { return@mapNotNull null @@ -316,22 +340,29 @@ object PluginManager { return@mapNotNull null } - //Omit lang not selected on language setting - val lang = sitePlugin.language ?: return@mapNotNull null - //If set to 'universal', don't skip any language - if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) { - return@mapNotNull null - } - //Log.i(TAG, "sitePlugin lang => $lang") - - //Omit NSFW, if disabled - sitePlugin.tvTypes?.let { tvtypes -> - if (!settingsForProvider.enableAdult) { - if (tvtypes.contains(TvType.NSFW.name)) { - return@mapNotNull null - } + //Omit non-NSFW if mode is set to NSFW only + if (mode == AutoDownloadMode.NsfwOnly) { + if (!tvtypes.contains(TvType.NSFW.name)) { + return@mapNotNull null } } + //Omit NSFW, if disabled + if (!settingsForProvider.enableAdult) { + if (tvtypes.contains(TvType.NSFW.name)) { + return@mapNotNull null + } + } + + //Omit lang not selected on language setting + if (mode == AutoDownloadMode.FilterByLang) { + val lang = sitePlugin.language ?: return@mapNotNull null + //If set to 'universal', don't skip any language + if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) { + return@mapNotNull null + } + //Log.i(TAG, "sitePlugin lang => $lang") + } + val savedData = PluginData( url = sitePlugin.url, internalName = sitePlugin.internalName, @@ -371,11 +402,11 @@ object PluginManager { /** * Use updateAllOnlinePluginsAndLoadThem * */ - fun loadAllOnlinePlugins(activity: Activity) { + fun loadAllOnlinePlugins(context: Context) { // Load all plugins as fast as possible! (getPluginsOnline()).toList().apmap { pluginData -> loadPlugin( - activity, + context, File(pluginData.filePath), pluginData ) @@ -398,9 +429,8 @@ object PluginManager { * @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins * and reload all pages even if they are previously valid **/ - fun loadAllLocalPlugins(activity: Activity, forceReload: Boolean) { + fun loadAllLocalPlugins(context: Context, forceReload: Boolean) { val dir = File(LOCAL_PLUGINS_PATH) - removeKey(PLUGINS_KEY_LOCAL) if (!dir.exists()) { val res = dir.mkdirs() @@ -416,7 +446,7 @@ object PluginManager { Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins") sortedPlugins?.sortedBy { it.name }?.apmap { file -> - maybeLoadPlugin(activity, file) + maybeLoadPlugin(context, file) } loadedLocalPlugins = true @@ -441,14 +471,22 @@ object PluginManager { /** * @return True if successful, false if not * */ - private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean { + private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { val fileName = file.nameWithoutExtension val filePath = file.absolutePath currentlyLoading = fileName Log.i(TAG, "Loading plugin: $data") return try { - val loader = PathClassLoader(filePath, activity.classLoader) + // in case of android 14 then + try { + File(filePath).setReadOnly() + } catch (t: Throwable) { + Log.e(TAG, "Failed to set dex as readonly") + logError(t) + } + + val loader = PathClassLoader(filePath, context.classLoader) var manifest: Plugin.Manifest loader.getResourceAsStream("manifest.json").use { stream -> if (stream == null) { @@ -469,10 +507,12 @@ object PluginManager { val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also { Log.d(TAG, "No manifest version for ${data.internalName}") } + + @Suppress("UNCHECKED_CAST") val pluginClass: Class<*> = loader.loadClass(manifest.pluginClassName) as Class val pluginInstance: Plugin = - pluginClass.newInstance() as Plugin + pluginClass.getDeclaredConstructor().newInstance() as Plugin // Sets with the proper version setPluginData(data.copy(version = version)) @@ -482,32 +522,34 @@ object PluginManager { return true } - pluginInstance.__filename = fileName + pluginInstance.filename = file.absolutePath if (manifest.requiresResources) { Log.d(TAG, "Loading resources for ${data.internalName}") // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk - val assets = AssetManager::class.java.newInstance() + val assets = AssetManager::class.java.getDeclaredConstructor().newInstance() val addAssetPath = AssetManager::class.java.getMethod("addAssetPath", String::class.java) addAssetPath.invoke(assets, file.absolutePath) + + @Suppress("DEPRECATION") pluginInstance.resources = Resources( assets, - activity.resources.displayMetrics, - activity.resources.configuration + context.resources.displayMetrics, + context.resources.configuration ) } plugins[filePath] = pluginInstance classLoaders[loader] = pluginInstance urlPlugins[data.url ?: filePath] = pluginInstance - pluginInstance.load(activity) + pluginInstance.load(context) Log.i(TAG, "Loaded plugin ${data.internalName} successfully") currentlyLoading = null true } catch (e: Throwable) { Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}") showToast( - activity, - activity.getString(R.string.plugin_load_fail).format(fileName), + context.getActivity(), + context.getString(R.string.plugin_load_fail).format(fileName), Toast.LENGTH_LONG ) currentlyLoading = null @@ -530,11 +572,15 @@ object PluginManager { } // remove all registered apis - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { - removePluginMapping(it) + synchronized(APIHolder.apis) { + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { + removePluginMapping(it) + } } - APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename } + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } + } + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } classLoaders.values.removeIf { v -> v == plugin } @@ -681,9 +727,14 @@ object PluginManager { } val notification = builder.build() - with(NotificationManagerCompat.from(context)) { - // notificationId is a unique int for each notification that you must define - notify((System.currentTimeMillis() / 1000).toInt(), notification) + // notificationId is a unique int for each notification that you must define + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(context) + .notify((System.currentTimeMillis() / 1000).toInt(), notification) } return notification } catch (e: Exception) { @@ -691,4 +742,4 @@ object PluginManager { return null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index e77b2d54..c6ec9df7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -2,8 +2,10 @@ package com.lagradost.cloudstream3.plugins import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError @@ -71,6 +73,15 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } + private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ + fun convertRawGitUrl(url: String): String { + if (getKey(context!!.getString(R.string.jsdelivr_proxy_key)) != true) return url + val match = GH_REGEX.find(url) ?: return url + val (user, repo, rest) = match.destructured + return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest" + } suspend fun parseRepoUrl(url: String): String? { val fixedUrl = url.trim() @@ -84,10 +95,11 @@ object RepositoryManager { } } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { suspendSafeApiCall { - app.get("https://l.cloudstream.cf/${fixedUrl}").let { - return@let if (it.isSuccessful && !it.url.startsWith("https://cutt.ly/branded-domains")) it.url - else app.get("https://cutt.ly/${fixedUrl}").let let2@{ it2 -> - return@let2 if (it2.isSuccessful) it2.url else null + app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 -> + it2.headers["Location"]?.let { url -> + if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null + if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null + return@suspendSafeApiCall url } } } @@ -97,14 +109,14 @@ object RepositoryManager { suspend fun parseRepository(url: String): Repository? { return suspendSafeApiCall { // Take manifestVersion and such into account later - app.get(url).parsedSafe() + app.get(convertRawGitUrl(url)).parsedSafe() } } private suspend fun parsePlugins(pluginUrls: String): List { // Take manifestVersion and such into account later return try { - val response = app.get(pluginUrls) + val response = app.get(convertRawGitUrl(pluginUrls)) // Normal parsed function not working? // return response.parsedSafe() tryParseJson>(response.text)?.toList() ?: emptyList() @@ -139,7 +151,7 @@ object RepositoryManager { } file.createNewFile() - val body = app.get(pluginUrl).okhttpResponse.body + val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body write(body.byteStream(), file.outputStream()) file } diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index f099ad1a..d1b702f4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -8,22 +8,14 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import java.security.MessageDigest import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock object VotingApi { // please do not cheat the votes lol private const val LOGKEY = "VotingApi" - enum class VoteType(val value: Int) { - UPVOTE(1), - DOWNVOTE(-1), - NONE(0) - } - - private val apiDomain = "https://api.countapi.xyz" + private const val API_DOMAIN = "https://counterapi.com/api" private fun transformUrl(url: String): String = // dont touch or all votes get reset MessageDigest @@ -35,12 +27,12 @@ object VotingApi { // please do not cheat the votes lol return getVotes(url) } - suspend fun SitePlugin.vote(requestType: VoteType): Int { - return vote(url, requestType) + fun SitePlugin.hasVoted(): Boolean { + return hasVoted(url) } - fun SitePlugin.getVoteType(): VoteType { - return getVoteType(url) + suspend fun SitePlugin.vote(): Int { + return vote(url) } fun SitePlugin.canVote(): Boolean { @@ -50,36 +42,38 @@ object VotingApi { // please do not cheat the votes lol // Plugin url to Int private val votesCache = mutableMapOf() - suspend fun getVotes(pluginUrl: String): Int { - val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}" + private fun getRepository(pluginUrl: String) = pluginUrl + .split("/") + .drop(2) + .take(3) + .joinToString("-") + + private suspend fun readVote(pluginUrl: String): Int { + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" Log.d(LOGKEY, "Requesting: $url") - return votesCache[pluginUrl] ?: app.get(url).parsedSafe()?.value?.also { - votesCache[pluginUrl] = it - } ?: (0.also { - ioSafe { - createBucket(pluginUrl) + return app.get(url).parsedSafe()?.value ?: 0 + } + + private suspend fun writeVote(pluginUrl: String): Boolean { + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" + Log.d(LOGKEY, "Requesting: $url") + return app.get(url).parsedSafe()?.value != null + } + + suspend fun getVotes(pluginUrl: String): Int = + votesCache[pluginUrl] ?: readVote(pluginUrl).also { + votesCache[pluginUrl] = it } - }) - } - fun getVoteType(pluginUrl: String): VoteType { - return getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE - } - - private suspend fun createBucket(pluginUrl: String) { - val url = - "${apiDomain}/create?namespace=cs3-votes&key=${transformUrl(pluginUrl)}&value=0&update_lowerbound=-2&update_upperbound=2&enable_reset=0" - Log.d(LOGKEY, "Requesting: $url") - app.get(url) - } + fun hasVoted(pluginUrl: String) = + getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false fun canVote(pluginUrl: String): Boolean { - if (!PluginManager.urlPlugins.contains(pluginUrl)) return false - return true + return PluginManager.urlPlugins.contains(pluginUrl) } private val voteLock = Mutex() - suspend fun vote(pluginUrl: String, requestType: VoteType): Int { + suspend fun vote(pluginUrl: String): Int { // Prevent multiple requests at the same time. voteLock.withLock { if (!canVote(pluginUrl)) { @@ -90,33 +84,21 @@ object VotingApi { // please do not cheat the votes lol return getVotes(pluginUrl) } - val savedType: VoteType = - getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE - - val newType = if (requestType == savedType) VoteType.NONE else requestType - val changeValue = if (requestType == savedType) { - -requestType.value - } else if (savedType == VoteType.NONE) { - requestType.value - } else if (savedType != requestType) { - -savedType.value + requestType.value - } else 0 - - // Pre-emptively set vote key - setKey("cs3-votes/${transformUrl(pluginUrl)}", newType) - - val url = - "${apiDomain}/update/cs3-votes/${transformUrl(pluginUrl)}?amount=${changeValue}" - Log.d(LOGKEY, "Requesting: $url") - val res = app.get(url).parsedSafe()?.value - - if (res == null) { - // "Refund" key if the response is invalid - setKey("cs3-votes/${transformUrl(pluginUrl)}", savedType) - } else { - votesCache[pluginUrl] = res + if (hasVoted(pluginUrl)) { + main { + Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) + .show() + } + return getVotes(pluginUrl) } - return res ?: 0 + + + if (writeVote(pluginUrl)) { + setKey("cs3-votes/${transformUrl(pluginUrl)}", true) + votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 + } + + return getVotes(pluginUrl) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt new file mode 100644 index 00000000..4ef841f5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt @@ -0,0 +1,96 @@ +package com.lagradost.cloudstream3.services + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import java.util.concurrent.TimeUnit + +const val BACKUP_CHANNEL_ID = "cloudstream3.backups" +const val BACKUP_WORK_NAME = "work_backup" +const val BACKUP_CHANNEL_NAME = "Backups" +const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups" +const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique + +class BackupWorkManager(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + companion object { + fun enqueuePeriodicWork(context: Context?, intervalHours: Long) { + if (context == null) return + + if (intervalHours == 0L) { + WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME) + return + } + + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .build() + + val periodicSyncDataWork = + PeriodicWorkRequest.Builder( + BackupWorkManager::class.java, + intervalHours, + TimeUnit.HOURS + ) + .addTag(BACKUP_WORK_NAME) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + BACKUP_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + periodicSyncDataWork + ) + + // Uncomment below for testing + +// val oneTimeBackupWork = +// OneTimeWorkRequest.Builder(BackupWorkManager::class.java) +// .addTag(BACKUP_WORK_NAME) +// .setConstraints(constraints) +// .build() +// +// WorkManager.getInstance(context).enqueue(oneTimeBackupWork) + } + } + + private val backupNotificationBuilder = + NotificationCompat.Builder(context, BACKUP_CHANNEL_ID) + .setColorized(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setAutoCancel(true) + .setContentTitle(context.getString(R.string.pref_category_backup)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) + + override suspend fun doWork(): Result { + context.createNotificationChannel( + BACKUP_CHANNEL_ID, + BACKUP_CHANNEL_NAME, + BACKUP_CHANNEL_DESCRIPTION + ) + + setForeground( + ForegroundInfo( + BACKUP_NOTIFICATION_ID, + backupNotificationBuilder.build() + ) + ) + + BackupUtils.backup(context) + + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt new file mode 100644 index 00000000..00c74dff --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -0,0 +1,235 @@ +package com.lagradost.cloudstream3.services + +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import androidx.work.* +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings +import com.lagradost.cloudstream3.utils.Coroutines.ioWork +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions +import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions" +const val SUBSCRIPTION_WORK_NAME = "work_subscription" +const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions" +const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows" +const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique + +class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + companion object { + fun enqueuePeriodicWork(context: Context?) { + if (context == null) return + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val periodicSyncDataWork = + PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS) + .addTag(SUBSCRIPTION_WORK_NAME) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + SUBSCRIPTION_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicSyncDataWork + ) + + // Uncomment below for testing + +// val oneTimeSyncDataWork = +// OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java) +// .addTag(SUBSCRIPTION_WORK_NAME) +// .setConstraints(constraints) +// .build() +// +// WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork) + } + } + + private val progressNotificationBuilder = + NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) + .setAutoCancel(false) + .setColorized(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setContentTitle(context.getString(R.string.subscription_in_progress_notification)) + .setSmallIcon(R.drawable.quantum_ic_refresh_white_24) + .setProgress(0, 0, true) + + private val updateNotificationBuilder = + NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) + .setColorized(true) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) + + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) { + notificationManager.notify( + SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder + .setProgress(max, progress, indeterminate) + .build() + ) + } + + @SuppressLint("UnspecifiedImmutableFlag") + override suspend fun doWork(): Result { + try { +// println("Update subscriptions!") + context.createNotificationChannel( + SUBSCRIPTION_CHANNEL_ID, + SUBSCRIPTION_CHANNEL_NAME, + SUBSCRIPTION_CHANNEL_DESCRIPTION + ) + + setForeground( + ForegroundInfo( + SUBSCRIPTION_NOTIFICATION_ID, + progressNotificationBuilder.build() + ) + ) + + val subscriptions = getAllSubscriptions() + + if (subscriptions.isEmpty()) { + WorkManager.getInstance(context).cancelWorkById(this.id) + return Result.success() + } + + val max = subscriptions.size + var progress = 0 + + updateProgress(max, progress, true) + + // We need all plugins loaded. + PluginManager.loadAllOnlinePlugins(context) + PluginManager.loadAllLocalPlugins(context, false) + + subscriptions.apmap { savedData -> + try { + val id = savedData.id ?: return@apmap null + val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null + + // Reasonable timeout to prevent having this worker run forever. + val response = withTimeoutOrNull(60_000) { + api.load(savedData.url) as? EpisodeResponse + } ?: return@apmap null + + val dubPreference = + getDub(id) ?: if ( + context.getApiDubstatusSettings().contains(DubStatus.Dubbed) + ) { + DubStatus.Dubbed + } else { + DubStatus.Subbed + } + + val latestEpisodes = response.getLatestEpisodes() + val latestPreferredEpisode = latestEpisodes[dubPreference] + + val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) { + val latestSeenEpisode = + savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE + val shouldUpdate = latestPreferredEpisode > latestSeenEpisode + shouldUpdate to latestPreferredEpisode + } else { + val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE + val latestSeenEpisode = + savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE + val shouldUpdate = latestEpisode > latestSeenEpisode + shouldUpdate to latestEpisode + } + + DataStoreHelper.updateSubscribedData( + id, + savedData, + response + ) + + if (shouldUpdate) { + val updateHeader = savedData.name + val updateDescription = txt( + R.string.subscription_episode_released, + latestEpisode, + savedData.name + ).asString(context) + + val intent = Intent(context, MainActivity::class.java).apply { + data = savedData.url.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } else { + PendingIntent.getActivity(context, 0, intent, 0) + } + + val poster = ioWork { + savedData.posterUrl?.let { url -> + context.getImageBitmapFromUrl( + url, + savedData.posterHeaders + ) + } + } + + val updateNotification = + updateNotificationBuilder.setContentTitle(updateHeader) + .setContentText(updateDescription) + .setContentIntent(pendingIntent) + .setLargeIcon(poster) + .build() + + notificationManager.notify(id, updateNotification) + } + + // You can probably get some issues here since this is async but it does not matter much. + updateProgress(max, ++progress, false) + } catch (t: Throwable) { + logError(t) + } + } + + return Result.success() + } catch (t: Throwable) { + logError(t) + // ye, while this is not correct, but because gods know why android just crashes + // and this causes major battery usage as it retries it inf times. This is better, just + // in case android decides to be android and fuck us + return Result.success() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt index be2fe75b..6151a0ed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -1,11 +1,22 @@ package com.lagradost.cloudstream3.services - -import android.app.IntentService +import android.app.Service import android.content.Intent +import android.os.IBinder import com.lagradost.cloudstream3.utils.VideoDownloadManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch -class VideoDownloadService : IntentService("VideoDownloadService") { - override fun onHandleIntent(intent: Intent?) { +class VideoDownloadService : Service() { + + private val downloadScope = CoroutineScope(Dispatchers.Default) + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent != null) { val id = intent.getIntExtra("id", -1) val type = intent.getStringExtra("type") @@ -14,10 +25,36 @@ class VideoDownloadService : IntentService("VideoDownloadService") { "resume" -> VideoDownloadManager.DownloadActionType.Resume "pause" -> VideoDownloadManager.DownloadActionType.Pause "stop" -> VideoDownloadManager.DownloadActionType.Stop - else -> return + else -> return START_NOT_STICKY + } + + downloadScope.launch { + VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) } - VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) } } + + return START_NOT_STICKY } -} \ No newline at end of file + + override fun onDestroy() { + downloadScope.coroutineContext.cancel() + super.onDestroy() + } +} +// override fun onHandleIntent(intent: Intent?) { +// if (intent != null) { +// val id = intent.getIntExtra("id", -1) +// val type = intent.getStringExtra("type") +// if (id != -1 && type != null) { +// val state = when (type) { +// "resume" -> VideoDownloadManager.DownloadActionType.Resume +// "pause" -> VideoDownloadManager.DownloadActionType.Pause +// "stop" -> VideoDownloadManager.DownloadActionType.Stop +// else -> return +// } +// VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) +// } +// } +// } +//} diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt index 77a1b0b5..df64caab 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,11 +1,23 @@ package com.lagradost.cloudstream3.subtitles import androidx.annotation.WorkerThread +import androidx.core.net.toUri +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.ui.player.SubtitleOrigin +import okio.BufferedSource +import okio.buffer +import okio.sink +import okio.source +import java.io.File +import java.util.zip.ZipInputStream interface AbstractSubProvider { + val idPrefix: String + @WorkerThread suspend fun search(query: SubtitleSearch): List? { throw NotImplementedError() @@ -15,6 +27,98 @@ interface AbstractSubProvider { suspend fun load(data: SubtitleEntity): String? { throw NotImplementedError() } + + @WorkerThread + suspend fun SubtitleResource.getResources(data: SubtitleEntity) { + this.addUrl(load(data)) + } + + @WorkerThread + suspend fun getResource(data: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(data) + } + } +} + +/** + * A builder for subtitle files. + * @see addUrl + * @see addFile + */ +class SubtitleResource { + fun downloadFile(source: BufferedSource): File { + val file = File.createTempFile("temp-subtitle", ".tmp").apply { + deleteFileOnExit(this) + } + val sink = file.sink().buffer() + sink.writeAll(source) + sink.close() + source.close() + + return file + } + + private fun unzip(file: File): List> { + val entries = mutableListOf>() + + ZipInputStream(file.inputStream()).use { zipInputStream -> + var zipEntry = zipInputStream.nextEntry + + while (zipEntry != null) { + val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply { + deleteFileOnExit(this) + } + entries.add(zipEntry.name to tempFile) + + tempFile.sink().buffer().use { buffer -> + buffer.writeAll(zipInputStream.source()) + } + + zipEntry = zipInputStream.nextEntry + } + } + return entries + } + + data class SingleSubtitleResource( + val name: String?, + val url: String, + val origin: SubtitleOrigin + ) + + private var resources: MutableList = mutableListOf() + + fun getSubtitles(): List { + return resources.toList() + } + + fun addUrl(url: String?, name: String? = null) { + if (url == null) return + this.resources.add( + SingleSubtitleResource(name, url, SubtitleOrigin.URL) + ) + } + + fun addFile(file: File, name: String? = null) { + this.resources.add( + SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE) + ) + deleteFileOnExit(file) + } + + suspend fun addZipUrl( + url: String, + nameGenerator: (String, File) -> String? = { _, _ -> null } + ) { + val source = app.get(url).okhttpResponse.body.source() + val zip = downloadFile(source) + val realFiles = unzip(zip) + zip.deleteRecursively() + realFiles.forEach { (name, subtitleFile) -> + addFile(subtitleFile, nameGenerator(name, subtitleFile)) + } + } } interface AbstractSubApi : AbstractSubProvider, AuthAPI \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt index f6424c4c..685b499b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt @@ -19,8 +19,11 @@ class AbstractSubtitleEntities { data class SubtitleSearch( var query: String = "", - var imdb: Long? = null, var lang: String? = null, + var imdbId: String? = null, + var tmdbId: Int? = null, + var malId: Int? = null, + var aniListId: Int? = null, var epNumber: Int? = null, var seasonNumber: Int? = null, var year: Int? = null diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index f17086c1..2e14c3c4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -3,61 +3,75 @@ package com.lagradost.cloudstream3.syncproviders import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.syncproviders.providers.* import java.util.concurrent.TimeUnit abstract class AccountManager(private val defIndex: Int) : AuthAPI { companion object { - val malApi = MALApi(0) - val aniListApi = AniListApi(0) + val malApi = MALApi(0).also { api -> + LoadResponse.Companion.malIdPrefix = api.idPrefix + } + val aniListApi = AniListApi(0).also { api -> + LoadResponse.Companion.aniListIdPrefix = api.idPrefix + } + val simklApi = SimklApi(0).also { api -> + LoadResponse.Companion.simklIdPrefix = api.idPrefix + } val openSubtitlesApi = OpenSubtitlesApi(0) - val indexSubtitlesApi = IndexSubtitleApi() val addic7ed = Addic7ed() + val subDlApi = SubDlApi(0) val localListApi = LocalList() + val subSourceApi = SubSourceApi() // used to login via app intent val OAuth2Apis get() = listOf( - malApi, aniListApi + malApi, aniListApi, simklApi ) // this needs init with context and can be accessed in settings val accountManagers get() = listOf( - malApi, aniListApi, openSubtitlesApi, //nginxApi + malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi ) // used for active syncing val SyncApis get() = listOf( - SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi) + SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi) ) val inAppAuths - get() = listOf(openSubtitlesApi)//, nginxApi) + get() = listOf( + openSubtitlesApi, + subDlApi + )//, nginxApi) val subtitleProviders get() = listOf( openSubtitlesApi, - indexSubtitlesApi, // they got anti scraping measures in place :( - addic7ed + addic7ed, + subDlApi, + subSourceApi ) - const val appString = "cloudstreamapp" - const val appStringRepo = "cloudstreamrepo" + const val APP_STRING = "cloudstreamapp" + const val APP_STRING_REPO = "cloudstreamrepo" + const val APP_STRING_PLAYER = "cloudstreamplayer" // Instantly start the search given a query - const val appStringSearch = "cloudstreamsearch" + const val APP_STRING_SEARCH = "cloudstreamsearch" // Instantly resume watching a show - const val appStringResumeWatching = "cloudstreamcontinuewatching" + const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" val unixTime: Long get() = System.currentTimeMillis() / 1000L val unixTimeMs: Long get() = System.currentTimeMillis() - const val maxStale = 60 * 10 + const val MAX_STALE = 60 * 10 fun secondsToReadable(seconds: Int, completedValue: String): String { var secondsLong = seconds.toLong() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt index ef74edfc..3d0bb940 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt @@ -5,7 +5,23 @@ import androidx.fragment.app.FragmentActivity interface OAuth2API : AuthAPI { val key: String val redirectUrl: String + val supportDeviceAuth: Boolean suspend fun handleRedirect(url: String) : Boolean fun authenticate(activity: FragmentActivity?) + suspend fun getDevicePin() : PinAuthData? { + return null + } + + suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean { + return false + } + + data class PinAuthData( + val deviceCode: String, + val userCode: String, + val verificationUrl: String, + val expiresIn: Int, + val interval: Int, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt similarity index 81% rename from app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt rename to app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt index 8c76c5bf..dcb8bbea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt @@ -1,17 +1,11 @@ package com.lagradost.cloudstream3.syncproviders import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.UiText import me.xdrop.fuzzywuzzy.FuzzySearch - -enum class SyncIdName { - Anilist, - MyAnimeList, - Trakt, - Imdb, - LocalList -} +import java.util.Date interface SyncAPI : OAuth2API { /** @@ -35,9 +29,9 @@ interface SyncAPI : OAuth2API { 4 -> PlanToWatch 5 -> ReWatching */ - suspend fun score(id: String, status: SyncStatus): Boolean + suspend fun score(id: String, status: AbstractSyncStatus): Boolean - suspend fun getStatus(id: String): SyncStatus? + suspend fun getStatus(id: String): AbstractSyncStatus? suspend fun getResult(id: String): SyncResult? @@ -59,14 +53,25 @@ interface SyncAPI : OAuth2API { override var id: Int? = null, ) : SearchResponse - data class SyncStatus( - val status: Int, + abstract class AbstractSyncStatus { + abstract var status: SyncWatchType + /** 1-10 */ - val score: Int?, - val watchedEpisodes: Int?, - var isFavorite: Boolean? = null, - var maxEpisodes: Int? = null, - ) + abstract var score: Int? + abstract var watchedEpisodes: Int? + abstract var isFavorite: Boolean? + abstract var maxEpisodes: Int? + } + + + data class SyncStatus( + override var status: SyncWatchType, + /** 1-10 */ + override var score: Int?, + override var watchedEpisodes: Int?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : AbstractSyncStatus() data class SyncResult( /**Used to verify*/ @@ -120,6 +125,8 @@ interface SyncAPI : OAuth2API { ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } + ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } + ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } else -> items } } @@ -154,6 +161,10 @@ interface SyncAPI : OAuth2API { override var posterUrl: String?, override var posterHeaders: Map?, override var quality: SearchQuality?, + val releaseDate: Date?, override var id: Int? = null, + val plot : String? = null, + val rating: Int? = null, + val tags: List? = null ) : SearchResponse } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt index 85b877e0..9363cb6f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) { repo.requireLibraryRefresh = value } - suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource { + suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource { return safeApiCall { repo.score(id, status) } } - suspend fun getStatus(id: String): Resource { + suspend fun getStatus(id: String): Resource { return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt index 507c5e2a..db467639 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt @@ -18,13 +18,13 @@ class Addic7ed : AbstractSubApi { override fun logOut() {} companion object { - const val host = "https://www.addic7ed.com" + const val HOST = "https://www.addic7ed.com" const val TAG = "ADDIC7ED" } private fun fixUrl(url: String): String { - return if (url.startsWith("/")) host + url - else if (!url.startsWith("http")) "$host/$url" + return if (url.startsWith("/")) HOST + url + else if (!url.startsWith("http")) "$HOST/$url" else url } @@ -62,7 +62,7 @@ class Addic7ed : AbstractSubApi { } val title = queryText.substringBefore("(").trim() - val url = "$host/search.php?search=${title}&Submit=Search" + val url = "$HOST/search.php?search=${title}&Submit=Search" val hostDocument = app.get(url).document var searchResult = "" if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url @@ -74,8 +74,8 @@ class Addic7ed : AbstractSubApi { hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") ?.substringBefore(",") val doc = app.get( - "$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", - referer = "$host/" + "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", + referer = "$HOST/" ).document doc.select("#season tr:contains($queryLang)").mapNotNull { node -> if (node.selectFirst("td")?.text() @@ -97,7 +97,7 @@ class Addic7ed : AbstractSubApi { val link = fixUrl(node.select("a.buttonDownload").attr("href")) val isHearingImpaired = !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty() - cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired) + cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired) } return results } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 7d9de43a..6112c7db 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -13,17 +13,19 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.splitQuery import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import java.net.URL import java.net.URLEncoder -import java.util.* +import java.util.Locale class AniListApi(index: Int) : AccountManager(index), SyncAPI { override var name = "AniList" @@ -31,6 +33,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override val redirectUrl = "anilistlogin" override val idPrefix = "anilist" override var requireLibraryRefresh = true + override val supportDeviceAuth = false override var mainUrl = "https://anilist.co" override val icon = R.drawable.ic_anilist_icon override val requiresLogin = false @@ -61,7 +64,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun handleRedirect(url: String): Boolean { val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR + splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR val token = sanitizer["access_token"]!! val expiresIn = sanitizer["expires_in"]!! @@ -85,7 +88,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun search(name: String): List? { val data = searchShows(name) ?: return null - return data.data?.Page?.media?.map { + return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, this.name, @@ -99,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun getResult(id: String): SyncAPI.SyncResult { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") - val season = getSeason(internalId).data.Media + val season = getSeason(internalId).data.media return SyncAPI.SyncResult( season.id.toString(), @@ -158,23 +161,23 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null val data = getDataAboutId(internalId) ?: return null return SyncAPI.SyncStatus( score = data.score, watchedEpisodes = data.progress, - status = data.type?.value ?: return null, + status = SyncWatchType.fromInternalId(data.type?.value ?: return null), isFavorite = data.isFavourite, maxEpisodes = data.episodes, ) } - override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return postDataAboutId( id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status), + fromIntToAnimeStatus(status.status.internalId), status.score, status.watchedEpisodes ).also { @@ -299,12 +302,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") val shows = searchShows(name.replace(blackListRegex, "")) - shows?.data?.Page?.media?.find { + shows?.data?.page?.media?.find { (malId ?: "NONE") == it.idMal.toString() }?.let { return it } val filtered = - shows?.data?.Page?.media?.filter { + shows?.data?.page?.media?.filter { (((it.startDate.year ?: year.toString()) == year.toString() || year == null)) } @@ -494,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { val data = postApi(q, true) val d = parseJson(data ?: return null) - val main = d.data?.Media + val main = d.data?.media if (main?.mediaListEntry != null) { return AniListTitleHolder( title = main.title, @@ -534,7 +537,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { headers = mapOf( "Authorization" to "Bearer " + (getAuth() ?: return@suspendSafeApiCall null), - if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache" + if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" ), cacheTime = 0, data = mapOf( @@ -595,7 +598,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { //@JsonProperty("source") val source: String, @JsonProperty("episodes") val episodes: Int, @JsonProperty("title") val title: Title, - //@JsonProperty("description") val description: String, + @JsonProperty("description") val description: String?, @JsonProperty("coverImage") val coverImage: CoverImage, @JsonProperty("synonyms") val synonyms: List, @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, @@ -629,7 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ?: this.media.coverImage.medium, null, null, - null + this.media.seasonYear.toYear(), + null, + plot = this.media.description, ) } } @@ -644,7 +649,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class Data( - @JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection + @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) private fun getAniListListCached(): Array? { @@ -656,7 +661,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { if (checkToken()) return null return if (requireLibraryRefresh) { - val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray() + val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { setKey(ANILIST_CACHED_LIST, list) } @@ -675,7 +680,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { // To fill empty lists when AniList does not return them val baseMap = - AniListStatusType.values().filter { it.value >= 0 }.associate { + AniListStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -686,6 +691,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) @@ -759,6 +766,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { return data != "" } + /** Used to query a saved MediaItem on the list to get the id for removal */ + data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null) + data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null) + data class MediaListId(@JsonProperty("id") val id: Long? = null) + private suspend fun postDataAboutId( id: Int, type: AniListStatusType, @@ -766,19 +778,43 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { progress: Int? ): Boolean { val q = - """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${ - aniListStatusString[maxOf( - 0, - type.value - )] - }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { - SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { - id - status - progress - score - } + // Delete item if status type is None + if (type == AniListStatusType.None) { + val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return false + // Get list ID for deletion + val idQuery = """ + query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) { + MediaList(userId: ${'$'}userId, mediaId: ${'$'}mediaId) { + id + } + } + """ + val response = postApi(idQuery) + val listId = + tryParseJson(response)?.data?.mediaList?.id ?: return false + """ + mutation(${'$'}id: Int = $listId) { + DeleteMediaListEntry(id: ${'$'}id) { + deleted + } + } + """ + } else { + """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${ + aniListStatusString[maxOf( + 0, + type.value + )] + }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { + SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { + id + status + progress + score + } }""" + } + val data = postApi(q) return data != "" } @@ -804,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { val data = postApi(q) if (data.isNullOrBlank()) return null val userData = parseJson(data) - val u = userData.data?.Viewer + val u = userData.data?.viewer val user = AniListUser( u?.id, u?.name, @@ -826,8 +862,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { suspend fun getSeasonRecursive(id: Int) { val season = getSeason(id) seasons.add(season) - if (season.data.Media.format?.startsWith("TV") == true) { - season.data.Media.relations?.edges?.forEach { + if (season.data.media.format?.startsWith("TV") == true) { + season.data.media.relations?.edges?.forEach { if (it.node?.format != null) { if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) { getSeasonRecursive(it.node.id) @@ -846,7 +882,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class SeasonData( - @JsonProperty("Media") val Media: SeasonMedia, + @JsonProperty("Media") val media: SeasonMedia, ) data class SeasonMedia( @@ -1018,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class AniListData( - @JsonProperty("Viewer") val Viewer: AniListViewer?, + @JsonProperty("Viewer") val viewer: AniListViewer?, ) data class AniListRoot( @@ -1058,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class LikeData( - @JsonProperty("Viewer") val Viewer: LikeViewer?, + @JsonProperty("Viewer") val viewer: LikeViewer?, ) data class LikeRoot( @@ -1098,7 +1134,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetDataData( - @JsonProperty("Media") val Media: GetDataMedia?, + @JsonProperty("Media") val media: GetDataMedia?, ) data class GetDataRoot( @@ -1131,7 +1167,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetSearchPage( - @JsonProperty("Page") val Page: GetSearchData?, + @JsonProperty("Page") val page: GetSearchData?, ) data class GetSearchData( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt index 7ec168da..94537ea3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt @@ -11,6 +11,7 @@ class Dropbox : OAuth2API { override val key = "zlqsamadlwydvb2" override val redirectUrl = "dropboxlogin" override val requiresLogin = true + override val supportDeviceAuth = false override val createAccountUrl: String? = null override val icon: Int diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt deleted file mode 100644 index 668d10bd..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders.providers - -import android.util.Log -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.imdbUrlToIdNullable -import com.lagradost.cloudstream3.subtitles.AbstractSubApi -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.utils.SubtitleHelper - -class IndexSubtitleApi : AbstractSubApi { - override val name = "IndexSubtitle" - override val idPrefix = "indexsubtitle" - override val requiresLogin = false - override val icon: Nothing? = null - override val createAccountUrl: Nothing? = null - - override fun loginInfo(): Nothing? = null - - override fun logOut() {} - - - companion object { - const val host = "https://indexsubtitle.com" - const val TAG = "INDEXSUBS" - } - - private fun fixUrl(url: String): String { - if (url.startsWith("http")) { - return url - } - if (url.isEmpty()) { - return "" - } - - val startsWithNoHttp = url.startsWith("//") - if (startsWithNoHttp) { - return "https:$url" - } else { - if (url.startsWith('/')) { - return host + url - } - return "$host/$url" - } - } - - private fun getOrdinal(num: Int?): String? { - return when (num) { - 1 -> "First" - 2 -> "Second" - 3 -> "Third" - 4 -> "Fourth" - 5 -> "Fifth" - 6 -> "Sixth" - 7 -> "Seventh" - 8 -> "Eighth" - 9 -> "Ninth" - 10 -> "Tenth" - 11 -> "Eleventh" - 12 -> "Twelfth" - 13 -> "Thirteenth" - 14 -> "Fourteenth" - 15 -> "Fifteenth" - 16 -> "Sixteenth" - 17 -> "Seventeenth" - 18 -> "Eighteenth" - 19 -> "Nineteenth" - 20 -> "Twentieth" - 21 -> "Twenty-First" - 22 -> "Twenty-Second" - 23 -> "Twenty-Third" - 24 -> "Twenty-Fourth" - 25 -> "Twenty-Fifth" - 26 -> "Twenty-Sixth" - 27 -> "Twenty-Seventh" - 28 -> "Twenty-Eighth" - 29 -> "Twenty-Ninth" - 30 -> "Thirtieth" - 31 -> "Thirty-First" - 32 -> "Thirty-Second" - 33 -> "Thirty-Third" - 34 -> "Thirty-Fourth" - 35 -> "Thirty-Fifth" - else -> null - } - } - - private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean { - val FILTER_EPS_REGEX = - Regex("(?i)((Chapter\\s?0?${epNum})|((Season)?\\s?0?${seasonNum}?\\s?(Episode)\\s?0?${epNum}[^0-9]))|(?i)((S?0?${seasonNum}?E0?${epNum}[^0-9])|(0?${seasonNum}[a-z]0?${epNum}[^0-9]))") - return text.contains(FILTER_EPS_REGEX) - } - - private fun haveEps(text: String): Boolean { - val HAVE_EPS_REGEX = - Regex("(?i)((Chapter\\s?0?\\d)|((Season)?\\s?0?\\d?\\s?(Episode)\\s?0?\\d))|(?i)((S?0?\\d?E0?\\d)|(0?\\d[a-z]0?\\d))") - return text.contains(HAVE_EPS_REGEX) - } - - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List { - val imdbId = query.imdb ?: 0 - val lang = query.lang - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) - val queryText = query.query - val epNum = query.epNumber ?: 0 - val seasonNum = query.seasonNumber ?: 0 - val yearNum = query.year ?: 0 - - val urlItems = ArrayList() - - fun cleanResources( - results: MutableList, - name: String, - link: String - ) { - results.add( - AbstractSubtitleEntities.SubtitleEntity( - idPrefix = idPrefix, - name = name, - lang = queryLang.toString(), - data = link, - source = this.name, - type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, - epNumber = epNum, - seasonNumber = seasonNum, - year = yearNum, - ) - ) - } - - val document = app.get("$host/?search=$queryText").document - - document.select("div.my-3.p-3 div.media").map { block -> - if (seasonNum > 0) { - val name = block.select("strong.text-primary, strong.text-info").text().trim() - val season = getOrdinal(seasonNum) - if ((block.selectFirst("a")?.attr("href") - ?.contains( - "$season", - ignoreCase = true - )!! || name.contains( - "$season", - ignoreCase = true - )) && name.contains(queryText, ignoreCase = true) - ) { - block.select("div.media").mapNotNull { - urlItems.add( - fixUrl( - it.selectFirst("a")!!.attr("href") - ) - ) - } - } - } else { - if (block.selectFirst("strong")!!.text().trim() - .matches(Regex("(?i)^$queryText\$")) - ) { - if (block.select("span[title=Release]").isNullOrEmpty()) { - block.select("div.media").mapNotNull { - val urlItem = fixUrl( - it.selectFirst("a")!!.attr("href") - ) - val itemDoc = app.get(urlItem).document - val id = imdbUrlToIdNullable( - itemDoc.selectFirst("div.d-flex span.badge.badge-primary")?.parent() - ?.attr("href") - )?.toLongOrNull() - val year = itemDoc.selectFirst("div.d-flex span.badge.badge-success") - ?.ownText() - ?.trim().toString() - Log.i(TAG, "id => $id \nyear => $year||$yearNum") - if (imdbId > 0) { - if (id == imdbId) { - urlItems.add(urlItem) - } - } else { - if (year.contains("$yearNum")) { - urlItems.add(urlItem) - } - } - } - } else { - if (block.select("span[title=Release]").text().trim() - .contains("$yearNum") - ) { - block.select("div.media").mapNotNull { - urlItems.add( - fixUrl( - it.selectFirst("a")!!.attr("href") - ) - ) - } - } - } - } - } - } - Log.i(TAG, "urlItems => $urlItems") - val results = mutableListOf() - - urlItems.forEach { url -> - val request = app.get(url) - if (request.isSuccessful) { - request.document.select("div.my-3.p-3 div.media").map { block -> - if (block.select("span.d-block span[data-original-title=Language]").text() - .trim() - .contains("$queryLang") - ) { - var name = block.select("strong.text-primary, strong.text-info").text().trim() - val link = fixUrl(block.selectFirst("a")!!.attr("href")) - if (seasonNum > 0) { - when { - isRightEps(name, seasonNum, epNum) -> { - cleanResources(results, name, link) - } - !(haveEps(name)) -> { - name = "$name (S${seasonNum}:E${epNum})" - cleanResources(results, name, link) - } - } - } else { - cleanResources(results, name, link) - } - } - } - } - } - return results - } - - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { - val seasonNum = data.seasonNumber - val epNum = data.epNumber - - val req = app.get(data.data) - - if (req.isSuccessful) { - val document = req.document - val link = if (document.select("div.my-3.p-3 div.media").size == 1) { - fixUrl( - document.selectFirst("div.my-3.p-3 div.media a")!!.attr("href") - ) - } else { - document.select("div.my-3.p-3 div.media").firstNotNullOf { block -> - val name = - block.selectFirst("strong.d-block")?.text()?.trim().toString() - if (seasonNum!! > 0) { - if (isRightEps(name, seasonNum, epNum)) { - fixUrl(block.selectFirst("a")!!.attr("href")) - } else { - null - } - } else { - fixUrl(block.selectFirst("a")!!.attr("href")) - } - } - } - return link - } - - return null - - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index 0b081220..0d9a4d13 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -8,7 +8,11 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioWork +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState @@ -17,6 +21,7 @@ class LocalList : SyncAPI { override val name = "Local" override val icon: Int = R.drawable.ic_baseline_storage_24 override val requiresLogin = false + override val supportDeviceAuth = false override val createAccountUrl: Nothing? = null override val idPrefix = "local" override var requireLibraryRefresh = true @@ -44,11 +49,11 @@ class LocalList : SyncAPI { override val mainUrl = "" override val syncIdName = SyncIdName.LocalList - override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return true } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { return null } @@ -68,28 +73,57 @@ class LocalList : SyncAPI { }?.distinctBy { it.first } ?: return null val list = ioWork { - watchStatusIds.groupBy { - it.second.stringRes - }.mapValues { group -> + val isTrueTv = isLayout(TV) + + val baseMap = WatchType.entries.filter { it != WatchType.NONE }.associate { + // None is not something to display + it.stringRes to emptyList() + } + mapOf( + R.string.favorites_list_name to emptyList() + ) + if (!isTrueTv) { + mapOf( + R.string.subscription_list_name to emptyList() + ) + } else { + emptyMap() + } + + val watchStatusMap = watchStatusIds.groupBy { it.second.stringRes }.mapValues { group -> group.value.mapNotNull { getBookmarkedData(it.first)?.toLibraryItem(it.first.toString()) } } + + val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull { + it.toLibraryItem() + }) + + // Don't show subscriptions on TV + val result = if (isTrueTv) { + baseMap + watchStatusMap + favoritesMap + } else { + val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { + it.toLibraryItem() + }) + + baseMap + watchStatusMap + subscriptionsMap + favoritesMap + } + + result } - val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate { - // None is not something to display - it.stringRes to emptyList() - } return SyncAPI.LibraryMetadata( - (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, + list.map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, -// ListSorting.UpdatedNew, -// ListSorting.UpdatedOld, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, // ListSorting.RatingHigh, // ListSorting.RatingLow, + ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index 5164b606..08c18653 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -16,16 +16,22 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.splitQuery import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import java.net.URL import java.security.SecureRandom import java.text.ParseException import java.text.SimpleDateFormat -import java.util.* +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */ const val MAL_MAX_SEARCH_LIMIT = 25 @@ -39,6 +45,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private val apiUrl = "https://api.myanimelist.net" override val icon = R.drawable.mal_logo override val requiresLogin = false + override val supportDeviceAuth = false override val syncIdName = SyncIdName.MyAnimeList override var requireLibraryRefresh = true override val createAccountUrl = "$mainUrl/register.php" @@ -49,7 +56,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } override fun loginInfo(): AuthAPI.LoginInfo? { - //getMalUser(true)? getKey(accountId, MAL_USER_KEY)?.let { user -> return AuthAPI.LoginInfo( profilePicture = user.picture, @@ -82,7 +88,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { this.name, node.id.toString(), "$mainUrl/anime/${node.id}/", - node.main_picture?.large ?: node.main_picture?.medium + node.mainPicture?.large ?: node.mainPicture?.medium ) } } @@ -91,10 +97,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() } - override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return setScoreRequest( id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status), + fromIntToAnimeStatus(status.status.internalId), status.score, status.watchedEpisodes ).also { @@ -176,7 +182,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDate(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time } catch (e: Exception) { null } @@ -188,7 +194,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { apiName = this.name, syncId = node.id.toString(), url = "$mainUrl/anime/${node.id}", - posterUrl = node.main_picture?.large + posterUrl = node.mainPicture?.large ) } @@ -242,12 +248,12 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { val internalId = id.toIntOrNull() ?: return null val data = - getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status") + getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status") return SyncAPI.SyncStatus( score = data?.score, - status = malStatusAsString.indexOf(data?.status), + status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), isFavorite = null, - watchedEpisodes = data?.num_episodes_watched, + watchedEpisodes = data?.numEpisodesWatched, ) } @@ -289,7 +295,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDateLong(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { @@ -300,7 +306,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun handleRedirect(url: String): Boolean { val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR + splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR val state = sanitizer["state"]!! if (state == "RequestID$requestId") { val currentCode = sanitizer["code"]!! @@ -349,9 +355,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { try { if (response != "") { val token = parseJson(response) - setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime)) - setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token) - setKey(accountId, MAL_TOKEN_KEY, token.access_token) + setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime)) + setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken) + setKey(accountId, MAL_TOKEN_KEY, token.accessToken) requireLibraryRefresh = true } } catch (e: Exception) { @@ -393,55 +399,62 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class Node( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String, - @JsonProperty("main_picture") val main_picture: MainPicture?, - @JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?, - @JsonProperty("media_type") val media_type: String?, - @JsonProperty("num_episodes") val num_episodes: Int?, + @JsonProperty("main_picture") val mainPicture: MainPicture?, + @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?, + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("num_episodes") val numEpisodes: Int?, @JsonProperty("status") val status: String?, - @JsonProperty("start_date") val start_date: String?, - @JsonProperty("end_date") val end_date: String?, - @JsonProperty("average_episode_duration") val average_episode_duration: Int?, + @JsonProperty("start_date") val startDate: String?, + @JsonProperty("end_date") val endDate: String?, + @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, @JsonProperty("synopsis") val synopsis: String?, @JsonProperty("mean") val mean: Double?, @JsonProperty("genres") val genres: List?, @JsonProperty("rank") val rank: Int?, @JsonProperty("popularity") val popularity: Int?, - @JsonProperty("num_list_users") val num_list_users: Int?, - @JsonProperty("num_favorites") val num_favorites: Int?, - @JsonProperty("num_scoring_users") val num_scoring_users: Int?, - @JsonProperty("start_season") val start_season: StartSeason?, + @JsonProperty("num_list_users") val numListUsers: Int?, + @JsonProperty("num_favorites") val numFavorites: Int?, + @JsonProperty("num_scoring_users") val numScoringUsers: Int?, + @JsonProperty("start_season") val startSeason: StartSeason?, @JsonProperty("broadcast") val broadcast: Broadcast?, @JsonProperty("nsfw") val nsfw: String?, - @JsonProperty("created_at") val created_at: String?, - @JsonProperty("updated_at") val updated_at: String? + @JsonProperty("created_at") val createdAt: String?, + @JsonProperty("updated_at") val updatedAt: String? ) data class ListStatus( @JsonProperty("status") val status: String?, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class Data( @JsonProperty("node") val node: Node, - @JsonProperty("list_status") val list_status: ListStatus?, + @JsonProperty("list_status") val listStatus: ListStatus?, ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.node.title, "https://myanimelist.net/anime/${this.node.id}/", this.node.id.toString(), - this.list_status?.num_episodes_watched, - this.node.num_episodes, - this.list_status?.score?.times(10), - parseDateLong(this.list_status?.updated_at), + this.listStatus?.numEpisodesWatched, + this.node.numEpisodes, + this.listStatus?.score?.times(10), + parseDateLong(this.listStatus?.updatedAt), "MAL", TvType.Anime, - this.node.main_picture?.large ?: this.node.main_picture?.medium, + this.node.mainPicture?.large ?: this.node.mainPicture?.medium, null, null, + plot = this.node.synopsis, + releaseDate = if (this.node.startDate == null) null else try {Date.from( + Instant.from( + DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") + .parse(this.node.startDate) + ) + )} catch (_: RuntimeException) {null} ) } } @@ -467,8 +480,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ) data class Broadcast( - @JsonProperty("day_of_the_week") val day_of_the_week: String?, - @JsonProperty("start_time") val start_time: String? + @JsonProperty("day_of_the_week") val dayOfTheWeek: String?, + @JsonProperty("start_time") val startTime: String? ) private fun getMalAnimeListCached(): Array? { @@ -488,14 +501,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { val list = getMalAnimeListSmart()?.groupBy { - convertToStatus(it.list_status?.status ?: "").stringRes + convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } } ?: emptyMap() // To fill empty lists when MAL does not return them val baseMap = - MalStatusType.values().filter { it.value >= 0 }.associate { + MalStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -506,6 +519,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) @@ -570,7 +585,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ).text val values = parseJson(res) val titles = - values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) } + values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) } for (t in titles) { allTitles[t.id] = t } @@ -579,11 +594,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? { + private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? { // No time remaining if the show has already ended try { endDate?.let { - if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null + if (SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it) + ?.before(Date.from(Instant.now())) != false + ) return@convertJapanTimeToTimeRemaining null } } catch (e: ParseException) { logError(e) @@ -600,7 +617,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH) val currentYear = currentDate.get(Calendar.YEAR) - val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm") + val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm", Locale.getDefault()) dateFormat.timeZone = TimeZone.getTimeZone("Japan") val parsedDate = dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null @@ -644,13 +661,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { id: Int, status: MalStatusType? = null, score: Int? = null, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): Boolean { val res = setScoreRequest( id, if (status == null) null else malStatusAsString[maxOf(0, status.value)], score, - num_watched_episodes + numWatchedEpisodes ) return if (res.isNullOrBlank()) { @@ -667,17 +684,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } + @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( id: Int, status: String? = null, score: Int? = null, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): String? { val data = mapOf( "status" to status, "score" to score?.toString(), - "num_watched_episodes" to num_watched_episodes?.toString() - ).filter { it.value != null } as Map + "num_watched_episodes" to numWatchedEpisodes?.toString() + ).filterValues { it != null } as Map return app.put( "$apiUrl/v2/anime/$id/my_list_status", @@ -690,10 +708,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class ResponseToken( - @JsonProperty("token_type") val token_type: String, - @JsonProperty("expires_in") val expires_in: Int, - @JsonProperty("access_token") val access_token: String, - @JsonProperty("refresh_token") val refresh_token: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, ) data class MalRoot( @@ -702,7 +720,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalDatum( @JsonProperty("node") val node: MalNode, - @JsonProperty("list_status") val list_status: MalStatus, + @JsonProperty("list_status") val listStatus: MalStatus, ) data class MalNode( @@ -719,16 +737,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalStatus( @JsonProperty("status") val status: String, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class MalUser( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("location") val location: String, - @JsonProperty("joined_at") val joined_at: String, + @JsonProperty("joined_at") val joinedAt: String, @JsonProperty("picture") val picture: String?, ) @@ -741,9 +759,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class SmallMalAnime( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String?, - @JsonProperty("num_episodes") val num_episodes: Int, - @JsonProperty("my_list_status") val my_list_status: MalStatus?, - @JsonProperty("main_picture") val main_picture: MalMainPicture?, + @JsonProperty("num_episodes") val numEpisodes: Int, + @JsonProperty("my_list_status") val myListStatus: MalStatus?, + @JsonProperty("main_picture") val mainPicture: MalMainPicture?, ) data class MalSearchNode( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 3e372c2d..37b95614 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -import com.google.common.collect.BiMap -import com.google.common.collect.HashBiMap -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities @@ -15,8 +16,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager import com.lagradost.cloudstream3.utils.AppUtils -import java.net.URLEncoder -import java.nio.charset.StandardCharsets +import okhttp3.Interceptor +import okhttp3.Response class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { override val idPrefix = "opensubtitles" @@ -28,14 +29,31 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi companion object { const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile - const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" - const val host = "https://api.opensubtitles.com/api/v1" + const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" + const val HOST = "https://api.opensubtitles.com/api/v1" const val TAG = "OPENSUBS" - const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms + const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms var currentCoolDown: Long = 0L var currentSession: SubtitleOAuthEntity? = null } + private val headerInterceptor = OpenSubtitleInterceptor() + + /** Automatically adds required api headers */ + private class OpenSubtitleInterceptor : Interceptor { + /** Required user agent! */ + private val userAgent = "Cloudstream3 v0.1" + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder() + .removeHeader("user-agent") + .addHeader("user-agent", userAgent) + .addHeader("Api-Key", API_KEY) + .build() + ) + } + } + private fun canDoRequest(): Boolean { return unixTimeMs > currentCoolDown } @@ -47,7 +65,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMs + coolDownDuration + currentCoolDown = unixTimeMs + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } @@ -96,15 +114,15 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi private suspend fun initLogin(username: String, password: String): Boolean { //Log.i(TAG, "DATA = [$username] [$password]") val response = app.post( - url = "$host/login", + url = "$HOST/login", headers = mapOf( - "Api-Key" to apiKey, - "Content-Type" to "application/json" + "Content-Type" to "application/json", ), data = mapOf( "username" to username, "password" to password - ) + ), + interceptor = headerInterceptor ) //Log.i(TAG, "Responsecode = ${response.code}") //Log.i(TAG, "Result => ${response.text}") @@ -115,7 +133,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi SubtitleOAuthEntity( user = username, pass = password, - access_token = token.token ?: run { + accessToken = token.token ?: run { return false }) ) @@ -149,11 +167,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi // "pt" to "pt-PT", // "pt" to "pt-BR" ) - private fun fixLanguage(language: String?) : String? { + + private fun fixLanguage(language: String?): String? { return languageExceptions[language] ?: language } + // O(n) but good enough, BiMap did not want to work properly - private fun fixLanguageReverse(language: String?) : String? { + private fun fixLanguageReverse(language: String?): String? { return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language } @@ -165,7 +185,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi throwIfCantDoRequest() val fixedLang = fixLanguage(query.lang) - val imdbId = query.imdb ?: 0 + val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 @@ -176,16 +196,16 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid - true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" - false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" } val req = app.get( url = searchQueryUrl, headers = mapOf( - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json") - ) + ), + interceptor = headerInterceptor ) Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { @@ -207,12 +227,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query - val lang = fixLanguageReverse(attr.language)?: "" + val lang = fixLanguageReverse(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie - val isHearingImpaired = attr.hearing_impaired ?: false + val isHearingImpaired = attr.hearingImpaired ?: false //Log.i(TAG, "Result id/name => ${item.id} / $name") item.attributes?.files?.forEach { file -> val resultData = file.fileId?.toString() ?: "" @@ -245,19 +265,19 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi throwIfCantDoRequest() val req = app.post( - url = "$host/download", + url = "$HOST/download", headers = mapOf( Pair( "Authorization", - "Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") ), data = mapOf( Pair("file_id", data.data) - ) + ), + interceptor = headerInterceptor ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") //Log.i(TAG, "Request headers => ${req.headers}") @@ -278,7 +298,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi data class SubtitleOAuthEntity( var user: String, var pass: String, - var access_token: String, + var accessToken: String, ) data class OAuthToken( @@ -303,7 +323,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi @JsonProperty("url") var url: String? = null, @JsonProperty("files") var files: List? = listOf(), @JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), - @JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null, + @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null, ) data class ResultFiles( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt new file mode 100644 index 00000000..50517f9d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -0,0 +1,1112 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SimklSyncServices +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mapper +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugPrint +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear +import okhttp3.Interceptor +import okhttp3.Response +import java.math.BigInteger +import java.security.SecureRandom +import java.text.SimpleDateFormat +import java.time.Instant +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +class SimklApi(index: Int) : AccountManager(index), SyncAPI { + override var name = "Simkl" + override val key = "simkl-key" + override val redirectUrl = "simkl" + override val supportDeviceAuth = true + override val idPrefix = "simkl" + override var requireLibraryRefresh = true + override var mainUrl = "https://api.simkl.com" + override val icon = R.drawable.simkl_logo + override val requiresLogin = false + override val createAccountUrl = "$mainUrl/signup" + override val syncIdName = SyncIdName.Simkl + private val token: String? + get() = getKey(accountId, SIMKL_TOKEN_KEY).also { + debugAssert({ it == null }) { "No ${this.name} token!" } + } + + /** Automatically adds simkl auth headers */ + private val interceptor = HeaderInterceptor() + + /** + * This is required to override the reported last activity as simkl activites + * may not always update based on testing. + */ + private var lastScoreTime = -1L + + private object SimklCache { + private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE" + + enum class CacheTimes(val value: String) { + OneMonth("30d"), + ThirtyMinutes("30m") + } + + private class SimklCacheWrapper( + @JsonProperty("obj") val obj: T?, + @JsonProperty("validUntil") val validUntil: Long, + @JsonProperty("cacheTime") val cacheTime: Long = unixTime, + ) { + /** Returns true if cache is newer than cacheDays */ + fun isFresh(): Boolean { + return validUntil > unixTime + } + + fun remainingTime(): Duration { + val unixTime = unixTime + return if (validUntil > unixTime) { + (validUntil - unixTime).toDuration(DurationUnit.SECONDS) + } else { + Duration.ZERO + } + } + } + + fun cleanOldCache() { + getKeys(SIMKL_CACHE_KEY)?.forEach { + val isOld = AcraApplication.getKey>(it)?.isFresh() == false + if (isOld) { + removeKey(it) + } + } + } + + fun setKey(path: String, value: T, cacheTime: Duration) { + debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." } + setKey( + SIMKL_CACHE_KEY, + path, + // Storing as plain sting is required to make generics work. + SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson() + ) + } + + /** + * Gets cached object, if object is not fresh returns null and removes it from cache + */ + inline fun getKey(path: String): T? { + // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" + val type = mapper.typeFactory.constructParametricType( + SimklCacheWrapper::class.java, + T::class.java + ) + val cache = getKey(SIMKL_CACHE_KEY, path)?.let { + mapper.readValue>(it, type) + } + + return if (cache?.isFresh() == true) { + debugPrint { + "Cache hit at: $SIMKL_CACHE_KEY/$path. " + + "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds." + } + cache.obj + } else { + debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" } + removeKey(SIMKL_CACHE_KEY, path) + null + } + } + } + + companion object { + private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID + private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET + private var lastLoginState = "" + + const val SIMKL_TOKEN_KEY: String = "simkl_token" + const val SIMKL_USER_KEY: String = "simkl_user" + const val SIMKL_CACHED_LIST: String = "simkl_cached_list" + const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" + + /** 2014-09-01T09:10:11Z -> 1409562611 */ + private const val SIMKL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'" + fun getUnixTime(string: String?): Long? { + return try { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { + this.timeZone = TimeZone.getTimeZone("UTC") + }.parse( + string ?: return null + )?.toInstant()?.epochSecond + } catch (e: Exception) { + logError(e) + return null + } + } + + /** 1409562611 -> 2014-09-01T09:10:11Z */ + fun getDateTime(unixTime: Long?): String? { + return try { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { + this.timeZone = TimeZone.getTimeZone("UTC") + }.format( + Date.from( + Instant.ofEpochSecond( + unixTime ?: return null + ) + ) + ) + } catch (e: Exception) { + null + } + } + + fun getPosterUrl(poster: String): String { + return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp" + } + + private fun getUrlFromId(id: Int): String { + return "https://simkl.com/shows/$id" + } + + enum class SimklListStatusType( + var value: Int, + @StringRes val stringRes: Int, + val originalName: String? + ) { + Watching(0, R.string.type_watching, "watching"), + Completed(1, R.string.type_completed, "completed"), + Paused(2, R.string.type_on_hold, "hold"), + Dropped(3, R.string.type_dropped, "dropped"), + Planning(4, R.string.type_plan_to_watch, "plantowatch"), + ReWatching(5, R.string.type_re_watching, "watching"), + None(-1, R.string.none, null); + + companion object { + fun fromString(string: String): SimklListStatusType? { + return SimklListStatusType.entries.firstOrNull { + it.originalName == string + } + } + } + } + + // ------------------- + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class TokenRequest( + @JsonProperty("code") val code: String, + @JsonProperty("client_id") val clientId: String = CLIENT_ID, + @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET, + @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", + @JsonProperty("grant_type") val grantType: String = "authorization_code" + ) + + data class TokenResponse( + /** No expiration date */ + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("scope") val scope: String + ) + // ------------------- + + /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */ + data class SettingsResponse( + val user: User + ) { + data class User( + val name: String, + /** Url */ + val avatar: String + ) + } + + data class PinAuthResponse( + @JsonProperty("result") val result: String, + @JsonProperty("device_code") val deviceCode: String, + @JsonProperty("user_code") val userCode: String, + @JsonProperty("verification_url") val verificationUrl: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("interval") val interval: Int, + ) + + data class PinExchangeResponse( + @JsonProperty("result") val result: String, + @JsonProperty("message") val message: String? = null, + @JsonProperty("access_token") val accessToken: String? = null, + ) + + // ------------------- + data class ActivitiesResponse( + @JsonProperty("all") val all: String?, + @JsonProperty("tv_shows") val tvShows: UpdatedAt, + @JsonProperty("anime") val anime: UpdatedAt, + @JsonProperty("movies") val movies: UpdatedAt, + ) { + data class UpdatedAt( + @JsonProperty("all") val all: String?, + @JsonProperty("removed_from_list") val removedFromList: String?, + @JsonProperty("rated_at") val ratedAt: String?, + ) + } + + /** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class EpisodeMetadata( + @JsonProperty("title") val title: String?, + @JsonProperty("description") val description: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("img") val img: String? + ) { + companion object { + fun convertToEpisodes(list: List?): List? { + return list?.map { + MediaObject.Season.Episode(it.episode) + } + } + + fun convertToSeasons(list: List?): List? { + return list?.filter { it.season != null }?.groupBy { + it.season + }?.mapNotNull { (season, episodes) -> + convertToEpisodes(episodes)?.let { MediaObject.Season(season!!, it) } + }?.ifEmpty { null } + } + } + } + + /** + * https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects + * Useful for finding shows from metadata + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + open class MediaObject( + @JsonProperty("title") val title: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids?, + @JsonProperty("total_episodes") val totalEpisodes: Int? = null, + @JsonProperty("status") val status: String? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("seasons") val seasons: List? = null, + @JsonProperty("episodes") val episodes: List? = null + ) { + fun hasEnded(): Boolean { + return status == "released" || status == "ended" + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Season( + @JsonProperty("number") val number: Int, + @JsonProperty("episodes") val episodes: List + ) { + data class Episode(@JsonProperty("number") val number: Int) + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Ids( + @JsonProperty("simkl") val simkl: Int?, + @JsonProperty("imdb") val imdb: String? = null, + @JsonProperty("tmdb") val tmdb: String? = null, + @JsonProperty("mal") val mal: String? = null, + @JsonProperty("anilist") val anilist: String? = null, + ) { + companion object { + fun fromMap(map: Map): Ids { + return Ids( + simkl = map[SimklSyncServices.Simkl]?.toIntOrNull(), + imdb = map[SimklSyncServices.Imdb], + tmdb = map[SimklSyncServices.Tmdb], + mal = map[SimklSyncServices.Mal], + anilist = map[SimklSyncServices.AniList] + ) + } + } + } + + fun toSyncSearchResult(): SyncAPI.SyncSearchResult? { + return SyncAPI.SyncSearchResult( + this.title ?: return null, + "Simkl", + this.ids?.simkl?.toString() ?: return null, + getUrlFromId(this.ids.simkl), + this.poster?.let { getPosterUrl(it) }, + if (this.type == "movie") TvType.Movie else TvType.TvSeries + ) + } + } + + class SimklScoreBuilder private constructor() { + data class Builder( + private var url: String? = null, + private var interceptor: Interceptor? = null, + private var ids: MediaObject.Ids? = null, + private var score: Int? = null, + private var status: Int? = null, + private var addEpisodes: Pair?, List?>? = null, + private var removeEpisodes: Pair?, List?>? = null, + // Required for knowing if the status should be overwritten + private var onList: Boolean = false + ) { + fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor } + fun apiUrl(url: String) = apply { this.url = url } + fun ids(ids: MediaObject.Ids) = apply { this.ids = ids } + fun score(score: Int?, oldScore: Int?) = apply { + if (score != oldScore) { + this.score = score + } + } + + fun status(newStatus: Int?, oldStatus: Int?) = apply { + onList = oldStatus != null + // Only set status if its new + if (newStatus != oldStatus) { + this.status = newStatus + } else { + this.status = null + } + } + + fun episodes( + allEpisodes: List?, + newEpisodes: Int?, + oldEpisodes: Int?, + ) = apply { + if (allEpisodes == null || newEpisodes == null) return@apply + + fun getEpisodes(rawEpisodes: List) = + if (rawEpisodes.any { it.season != null }) { + EpisodeMetadata.convertToSeasons(rawEpisodes) to null + } else { + null to EpisodeMetadata.convertToEpisodes(rawEpisodes) + } + + // Do not add episodes if there is no change + if (newEpisodes > (oldEpisodes ?: 0)) { + this.addEpisodes = getEpisodes(allEpisodes.take(newEpisodes)) + + // Set to watching if episodes are added and there is no current status + if (!onList) { + status = SimklListStatusType.Watching.value + } + } + if ((oldEpisodes ?: 0) > newEpisodes) { + this.removeEpisodes = getEpisodes(allEpisodes.drop(newEpisodes)) + } + } + + suspend fun execute(): Boolean { + val time = getDateTime(unixTime) + + return if (this.status == SimklListStatusType.None.value) { + app.post( + "$url/sync/history/remove", + json = StatusRequest( + shows = listOf(HistoryMediaObject(ids = ids)), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } else { + val statusResponse = this.status?.let { setStatus -> + val newStatus = + SimklListStatusType.entries + .firstOrNull { it.value == setStatus }?.originalName + ?: SimklListStatusType.Watching.originalName!! + + app.post( + "${this.url}/sync/add-to-list", + json = StatusRequest( + shows = listOf( + StatusMediaObject( + null, + null, + ids, + newStatus, + ) + ), movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } ?: true + + val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) -> + app.post( + "${this.url}/sync/history/remove", + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + ids = ids, + seasons = seasons, + episodes = episodes + ) + ), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } ?: true + + // You cannot rate if you are planning to watch it. + val shouldRate = + score != null && status != SimklListStatusType.Planning.value + val realScore = if (shouldRate) score else null + + val historyResponse = + // Only post if there are episodes or score to upload + if (addEpisodes != null || shouldRate) { + app.post( + "${this.url}/sync/history", + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + null, + null, + ids, + addEpisodes?.first, + addEpisodes?.second, + realScore, + realScore?.let { time }, + ) + ), movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } else { + true + } + + statusResponse && episodeRemovalResponse && historyResponse + } + } + } + } + + suspend fun getEpisodes( + simklId: Int?, + type: String?, + episodes: Int?, + hasEnded: Boolean? + ): Array? { + if (simklId == null) return null + + val cacheKey = "Episodes/$simklId" + val cache = SimklCache.getKey>(cacheKey) + + // Return cached result if its higher or equal the amount of episodes. + if (cache != null && cache.size >= (episodes ?: 0)) { + return cache + } + + // There is always one season in Anime -> no request necessary + if (type == "anime" && episodes != null) { + return episodes.takeIf { it > 0 }?.let { + (1..it).map { episode -> + EpisodeMetadata( + null, null, null, episode, null + ) + }.toTypedArray() + } + } + val url = when (type) { + "anime" -> "https://api.simkl.com/anime/episodes/$simklId" + "tv" -> "https://api.simkl.com/tv/episodes/$simklId" + "movie" -> return null + else -> return null + } + + debugPrint { "Requesting episodes from $url" } + return app.get(url, params = mapOf("client_id" to CLIENT_ID)) + .parsedSafe>()?.also { + val cacheTime = + if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value + + // 1 Month cache + SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime)) + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class HistoryMediaObject( + @JsonProperty("title") title: String? = null, + @JsonProperty("year") year: Int? = null, + @JsonProperty("ids") ids: Ids? = null, + @JsonProperty("seasons") seasons: List? = null, + @JsonProperty("episodes") episodes: List? = null, + @JsonProperty("rating") val rating: Int? = null, + @JsonProperty("rated_at") val ratedAt: String? = null, + ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class RatingMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("rating") val rating: Int, + @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class StatusMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("to") val to: String, + @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class StatusRequest( + @JsonProperty("movies") val movies: List, + @JsonProperty("shows") val shows: List + ) + + /** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */ + data class AllItemsResponse( + @JsonProperty("shows") + val shows: List = emptyList(), + @JsonProperty("anime") + val anime: List = emptyList(), + @JsonProperty("movies") + val movies: List = emptyList(), + ) { + companion object { + fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse { + + // Replace the first item with the same id, or add the new item + fun MutableList.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) { + for (i in this.indices) { + if (predicate(this[i])) { + this[i] = newItem + return + } + } + this.add(newItem) + } + + // + fun merge( + first: List?, + second: List? + ): List { + return (first?.toMutableList() ?: mutableListOf()).apply { + second?.forEach { secondShow -> + this.replaceOrAddItem(secondShow) { + it.getIds().simkl == secondShow.getIds().simkl + } + } + } + } + + return AllItemsResponse( + merge(first?.shows, second?.shows), + merge(first?.anime, second?.anime), + merge(first?.movies, second?.movies), + ) + } + } + + interface Metadata { + val lastWatchedAt: String? + val status: String? + val userRating: Int? + val lastWatched: String? + val watchedEpisodesCount: Int? + val totalEpisodesCount: Int? + + fun getIds(): ShowMetadata.Show.Ids + fun toLibraryItem(): SyncAPI.LibraryItem + } + + data class MovieMetadata( + @JsonProperty("last_watched_at") override val lastWatchedAt: String?, + @JsonProperty("status") override val status: String, + @JsonProperty("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, + val movie: ShowMetadata.Show + ) : Metadata { + override fun getIds(): ShowMetadata.Show.Ids { + return this.movie.ids + } + + override fun toLibraryItem(): SyncAPI.LibraryItem { + return SyncAPI.LibraryItem( + this.movie.title, + "https://simkl.com/tv/${movie.ids.simkl}", + movie.ids.simkl.toString(), + this.watchedEpisodesCount, + this.totalEpisodesCount, + this.userRating?.times(10), + getUnixTime(lastWatchedAt) ?: 0, + "Simkl", + TvType.Movie, + this.movie.poster?.let { getPosterUrl(it) }, + null, + null, + this.movie.year?.toYear(), + movie.ids.simkl + ) + } + } + + data class ShowMetadata( + @JsonProperty("last_watched_at") override val lastWatchedAt: String?, + @JsonProperty("status") override val status: String, + @JsonProperty("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, + @JsonProperty("show") val show: Show + ) : Metadata { + override fun getIds(): Show.Ids { + return this.show.ids + } + + override fun toLibraryItem(): SyncAPI.LibraryItem { + return SyncAPI.LibraryItem( + this.show.title, + "https://simkl.com/tv/${show.ids.simkl}", + show.ids.simkl.toString(), + this.watchedEpisodesCount, + this.totalEpisodesCount, + this.userRating?.times(10), + getUnixTime(lastWatchedAt) ?: 0, + "Simkl", + TvType.Anime, + this.show.poster?.let { getPosterUrl(it) }, + null, + null, + this.show.year?.toYear(), + show.ids.simkl + ) + } + + data class Show( + @JsonProperty("title") val title: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids, + ) { + data class Ids( + @JsonProperty("simkl") val simkl: Int, + @JsonProperty("slug") val slug: String?, + @JsonProperty("imdb") val imdb: String?, + @JsonProperty("zap2it") val zap2it: String?, + @JsonProperty("tmdb") val tmdb: String?, + @JsonProperty("offen") val offen: String?, + @JsonProperty("tvdb") val tvdb: String?, + @JsonProperty("mal") val mal: String?, + @JsonProperty("anidb") val anidb: String?, + @JsonProperty("anilist") val anilist: String?, + @JsonProperty("traktslug") val traktslug: String? + ) { + fun matchesId(database: SimklSyncServices, id: String): Boolean { + return when (database) { + SimklSyncServices.Simkl -> this.simkl == id.toIntOrNull() + SimklSyncServices.AniList -> this.anilist == id + SimklSyncServices.Mal -> this.mal == id + SimklSyncServices.Tmdb -> this.tmdb == id + SimklSyncServices.Imdb -> this.imdb == id + } + } + } + } + } + } + } + + /** + * Appends api keys to the requests + **/ + private inner class HeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" } + return chain.proceed( + chain.request() + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .addHeader("simkl-api-key", CLIENT_ID) + .build() + ) + } + } + + private suspend fun getUser(): SettingsResponse.User? { + return suspendSafeApiCall { + app.post("$mainUrl/users/settings", interceptor = interceptor) + .parsedSafe()?.user + } + } + + /** + * Useful to get episodes on demand to prevent unnecessary requests. + */ + class SimklEpisodeConstructor( + private val simklId: Int?, + private val type: String?, + private val totalEpisodeCount: Int?, + private val hasEnded: Boolean? + ) { + suspend fun getEpisodes(): Array? { + return getEpisodes(simklId, type, totalEpisodeCount, hasEnded) + } + } + + class SimklSyncStatus( + override var status: SyncWatchType, + override var score: Int?, + val oldScore: Int?, + override var watchedEpisodes: Int?, + val episodeConstructor: SimklEpisodeConstructor, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + /** Save seen episodes separately to know the change from old to new. + * Required to remove seen episodes if count decreases */ + val oldEpisodes: Int, + val oldStatus: String? + ) : SyncAPI.AbstractSyncStatus() + + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + val realIds = readIdFromString(id) + + // Key which assumes all ids are the same each time :/ + // This could be some sort of reference system to make multiple IDs + // point to the same key. + val idKey = + realIds.toList().map { "${it.first.originalName}=${it.second}" }.sorted().joinToString() + + val cachedObject = SimklCache.getKey(idKey) + val searchResult: MediaObject = cachedObject + ?: (searchByIds(realIds)?.firstOrNull()?.also { result -> + val cacheTime = + if (result.hasEnded()) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value + SimklCache.setKey(idKey, result, Duration.parse(cacheTime)) + }) ?: return null + + val episodeConstructor = SimklEpisodeConstructor( + searchResult.ids?.simkl, + searchResult.type, + searchResult.totalEpisodes, + searchResult.hasEnded() + ) + + val foundItem = getSyncListSmart()?.let { list -> + listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> + realIds.any { (database, id) -> + show.getIds().matchesId(database, id) + } + } + } + + if (foundItem != null) { + return SimklSyncStatus( + status = foundItem.status?.let { + SyncWatchType.fromInternalId( + SimklListStatusType.fromString( + it + )?.value + ) + } + ?: return null, + score = foundItem.userRating, + watchedEpisodes = foundItem.watchedEpisodesCount, + maxEpisodes = searchResult.totalEpisodes, + episodeConstructor = episodeConstructor, + oldEpisodes = foundItem.watchedEpisodesCount ?: 0, + oldScore = foundItem.userRating, + oldStatus = foundItem.status + ) + } else { + return SimklSyncStatus( + status = SyncWatchType.fromInternalId(SimklListStatusType.None.value), + score = 0, + watchedEpisodes = 0, + maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes, + episodeConstructor = episodeConstructor, + oldEpisodes = 0, + oldStatus = null, + oldScore = null + ) + } + } + + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + val parsedId = readIdFromString(id) + lastScoreTime = unixTime + val simklStatus = status as? SimklSyncStatus + + val builder = SimklScoreBuilder.Builder() + .apiUrl(this.mainUrl) + .score(status.score, simklStatus?.oldScore) + .status( + status.status.internalId, + (status as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> + SimklListStatusType.entries.firstOrNull { + it.originalName == oldStatus + }?.value + }) + .interceptor(interceptor) + .ids(MediaObject.Ids.fromMap(parsedId)) + + + // Get episodes only when required + val episodes = simklStatus?.episodeConstructor?.getEpisodes() + + // All episodes if marked as completed + val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) { + episodes?.size + } else { + status.watchedEpisodes + } + + builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes) + + requireLibraryRefresh = true + return builder.execute() + } + + + /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ + private suspend fun searchByIds(serviceMap: Map): Array? { + if (serviceMap.isEmpty()) return emptyArray() + + return app.get( + "$mainUrl/search/id", + params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) -> + service.originalName to id + } + ).parsedSafe() + } + + override suspend fun search(name: String): List? { + return app.get( + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) + ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } + } + + override fun authenticate(activity: FragmentActivity?) { + lastLoginState = BigInteger(130, SecureRandom()).toString(32) + val url = + "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState" + openBrowser(url, activity) + } + + override fun loginInfo(): AuthAPI.LoginInfo? { + return getKey(accountId, SIMKL_USER_KEY)?.let { user -> + AuthAPI.LoginInfo( + name = user.name, + profilePicture = user.avatar, + accountIndex = accountIndex + ) + } + } + + override fun logOut() { + requireLibraryRefresh = true + removeAccountKeys() + } + + override suspend fun getResult(id: String): SyncAPI.SyncResult? { + return null + } + + private suspend fun getSyncListSince(since: Long?): AllItemsResponse? { + val params = getDateTime(since)?.let { + mapOf("date_from" to it) + } ?: emptyMap() + + // Can return null on no change. + return app.get( + "$mainUrl/sync/all-items/", + params = params, + interceptor = interceptor + ).parsedSafe() + } + + private suspend fun getActivities(): ActivitiesResponse? { + return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe() + } + + private fun getSyncListCached(): AllItemsResponse? { + return getKey(accountId, SIMKL_CACHED_LIST) + } + + private suspend fun getSyncListSmart(): AllItemsResponse? { + if (token == null) return null + + val activities = getActivities() + val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME) + val lastRemoval = listOf( + activities?.tvShows?.removedFromList, + activities?.anime?.removedFromList, + activities?.movies?.removedFromList + ).maxOf { + getUnixTime(it) ?: -1 + } + val lastRealUpdate = + listOf( + activities?.tvShows?.all, + activities?.anime?.all, + activities?.movies?.all, + ).maxOf { + getUnixTime(it) ?: -1 + } + + debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" } + val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) { + debugPrint { "Full list update in ${this.name}." } + setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval) + getSyncListSince(null) + } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { + debugPrint { "Partial list update in ${this.name}." } + setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate) + AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate)) + } else { + debugPrint { "Cached list update in ${this.name}." } + getSyncListCached() + } + debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } + + setKey(accountId, SIMKL_CACHED_LIST, list) + + return list + } + + + override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart() ?: return null + + val baseMap = + SimklListStatusType.entries + .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } + .associate { + it.stringRes to emptyList() + } + + val syncMap = listOf(list.anime, list.movies, list.shows) + .flatten() + .groupBy { + it.status + } + .mapNotNull { (status, list) -> + val stringRes = + status?.let { SimklListStatusType.fromString(it)?.stringRes } + ?: return@mapNotNull null + val libraryList = list.map { it.toLibraryItem() } + stringRes to libraryList + }.toMap() + + return SyncAPI.LibraryMetadata( + (baseMap + syncMap).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + override fun getIdFromUrl(url: String): String { + val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""") + return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" + } + + override suspend fun getDevicePin(): OAuth2API.PinAuthData? { + val pinAuthResp = app.get( + "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}" + ).parsedSafe() ?: return null + + return OAuth2API.PinAuthData( + deviceCode = pinAuthResp.deviceCode, + userCode = pinAuthResp.userCode, + verificationUrl = pinAuthResp.verificationUrl, + expiresIn = pinAuthResp.expiresIn, + interval = pinAuthResp.interval + ) + } + + override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean { + val pinAuthResp = app.get( + "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID" + ).parsedSafe() ?: return false + + if (pinAuthResp.accessToken != null) { + switchToNewAccount() + setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken) + + val user = getUser() + if (user == null) { + removeKey(accountId, SIMKL_TOKEN_KEY) + switchToOldAccount() + return false + } + + setKey(accountId, SIMKL_USER_KEY, user) + registerAccount() + requireLibraryRefresh = true + return true + } + return false + } + + override suspend fun handleRedirect(url: String): Boolean { + val uri = url.toUri() + val state = uri.getQueryParameter("state") + // Ensure consistent state + if (state != lastLoginState) return false + lastLoginState = "" + + val code = uri.getQueryParameter("code") ?: return false + val token = app.post( + "$mainUrl/oauth/token", json = TokenRequest(code) + ).parsedSafe() ?: return false + + switchToNewAccount() + setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken) + + val user = getUser() + if (user == null) { + removeKey(accountId, SIMKL_TOKEN_KEY) + switchToOldAccount() + return false + } + + setKey(accountId, SIMKL_USER_KEY, user) + registerAccount() + requireLibraryRefresh = true + + return true + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt new file mode 100644 index 00000000..8dad1f88 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt @@ -0,0 +1,159 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.subtitles.AbstractSubProvider +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.SubtitleHelper + +class SubSourceApi : AbstractSubProvider { + override val idPrefix = "subsource" + val name = "SubSource" + + companion object { + const val APIURL = "https://api.subsource.net/api" + const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub" + } + + override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { + + //Only supports Imdb Id search for now + if (query.imdbId == null) return null + val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!) + val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie + + val searchRes = app.post( + url = "$APIURL/searchMovie", + data = mapOf( + "query" to query.imdbId!! + ) + ).parsedSafe() ?: return null + + val postData = if (type == TvType.TvSeries) { + mapOf( + "langs" to "[]", + "movieName" to searchRes.found.first().linkName, + "season" to "season-${query.seasonNumber}" + ) + } else { + mapOf( + "langs" to "[]", + "movieName" to searchRes.found.first().linkName, + ) + } + + val getMovieRes = app.post( + url = "$APIURL/getMovie", + data = postData + ).parsedSafe().let { + // api doesn't has episode number or lang filtering + if (type == TvType.Movie) { + it?.subs?.filter { sub -> + sub.lang == queryLang + } + } else { + it?.subs?.filter { sub -> + sub.releaseName!!.contains( + String.format( + null, + "E%02d", + query.epNumber + ) + ) && sub.lang == queryLang + } + } + } ?: return null + + return getMovieRes.map { subtitle -> + AbstractSubtitleEntities.SubtitleEntity( + idPrefix = this.idPrefix, + name = subtitle.releaseName!!, + lang = subtitle.lang!!, + data = SubData( + movie = subtitle.linkName!!, + lang = subtitle.lang, + id = subtitle.subId.toString(), + ).toJson(), + type = type, + source = this.name, + epNumber = query.epNumber, + seasonNumber = query.seasonNumber, + isHearingImpaired = subtitle.hi == 1, + ) + } + } + + override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) { + + val parsedSub = parseJson(data.data) + + val subRes = app.post( + url = "$APIURL/getSub", + data = mapOf( + "movie" to parsedSub.movie, + "lang" to data.lang, + "id" to parsedSub.id + ) + ).parsedSafe() ?: return + + this.addZipUrl( + "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}" + ) { name, _ -> + name + } + } + + data class ApiSearch( + @JsonProperty("success") val success: Boolean, + @JsonProperty("found") val found: List, + ) + + data class Found( + @JsonProperty("id") val id: Long, + @JsonProperty("title") val title: String, + @JsonProperty("seasons") val seasons: Long, + @JsonProperty("type") val type: String, + @JsonProperty("releaseYear") val releaseYear: Long, + @JsonProperty("linkName") val linkName: String, + ) + + data class ApiResponse( + @JsonProperty("success") val success: Boolean, + @JsonProperty("movie") val movie: Movie, + @JsonProperty("subs") val subs: List, + ) + + data class Movie( + @JsonProperty("id") val id: Long? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("year") val year: Long? = null, + @JsonProperty("fullName") val fullName: String? = null, + ) + + data class Sub( + @JsonProperty("hi") val hi: Int? = null, + @JsonProperty("fullLink") val fullLink: String? = null, + @JsonProperty("linkName") val linkName: String? = null, + @JsonProperty("lang") val lang: String? = null, + @JsonProperty("releaseName") val releaseName: String? = null, + @JsonProperty("subId") val subId: Long? = null, + ) + + data class SubData( + @JsonProperty("movie") val movie: String, + @JsonProperty("lang") val lang: String, + @JsonProperty("id") val id: String, + ) + + data class SubTitleLink( + @JsonProperty("sub") val sub: SubToken, + ) + + data class SubToken( + @JsonProperty("downloadToken") val downloadToken: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt new file mode 100644 index 00000000..29544e65 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt @@ -0,0 +1,247 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.subtitles.AbstractSubApi +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo +import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI +import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager + +class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { + override val idPrefix = "subdl" + override val name = "SubDL" + override val icon = R.drawable.subdl_logo_big + override val requiresPassword = true + override val requiresEmail = true + override val createAccountUrl = "https://subdl.com/login" + + companion object { + const val APIURL = "https://api.subdl.com" + const val APIENDPOINT = "$APIURL/api/v1/subtitles" + const val DOWNLOADENDPOINT = "https://dl.subdl.com" + const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user" + var currentSession: SubtitleOAuthEntity? = null + } + + override suspend fun initialize() { + currentSession = getAuthKey() + } + + override fun logOut() { + setAuthKey(null) + removeAccountKeys() + currentSession = getAuthKey() + } + override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { + val email = data.email ?: throw ErrorLoadingException("Requires Email") + val password = data.password ?: throw ErrorLoadingException("Requires Password") + switchToNewAccount() + try { + if (initLogin(email, password)) { + registerAccount() + return true + } + } catch (e: Exception) { + logError(e) + switchToOldAccount() + } + switchToOldAccount() + return false + } + + override fun getLatestLoginData(): InAppAuthAPI.LoginData? { + val current = getAuthKey() ?: return null + return InAppAuthAPI.LoginData( + email = current.userEmail, + password = current.pass + ) + } + + override fun loginInfo(): LoginInfo? { + getAuthKey()?.let { user -> + return LoginInfo( + profilePicture = null, + name = user.name ?: user.userEmail, + accountIndex = accountIndex + ) + } + return null + } + + override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { + + val queryText = query.query + val epNum = query.epNumber ?: 0 + val seasonNum = query.seasonNumber ?: 0 + val yearNum = query.year ?: 0 + + val idQuery = when { + query.imdbId != null -> "&imdb_id=${query.imdbId}" + query.tmdbId != null -> "&tmdb_id=${query.tmdbId}" + else -> null + } + + val epQuery = if (epNum > 0) "&episode_number=$epNum" else "" + val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else "" + val yearQuery = if (yearNum > 0) "&year=$yearNum" else "" + + val searchQueryUrl = when (idQuery) { + //Use imdb/tmdb id to search if its valid + null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + } + + val req = app.get( + url = searchQueryUrl, + headers = mapOf( + "Accept" to "application/json" + ) + ) + + return req.parsedSafe()?.subtitles?.map { subtitle -> + + val lang = subtitle.lang.replaceFirstChar { it.uppercase() } + val resEpNum = subtitle.episode ?: query.epNumber + val resSeasonNum = subtitle.season ?: query.seasonNumber + val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie + + AbstractSubtitleEntities.SubtitleEntity( + idPrefix = this.idPrefix, + name = subtitle.releaseName, + lang = lang, + data = "${DOWNLOADENDPOINT}${subtitle.url}", + type = type, + source = this.name, + epNumber = resEpNum, + seasonNumber = resSeasonNum, + isHearingImpaired = subtitle.hearingImpaired ?: false, + ) + } + } + + override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) { + this.addZipUrl(data.data) { name, _ -> + name + } + } + + private suspend fun initLogin(useremail: String, password: String): Boolean { + + val tokenResponse = app.post( + url = "$APIURL/login", + data = mapOf( + "email" to useremail, + "password" to password + ) + ).parsedSafe() + + if (tokenResponse?.token == null) return false + + val apiResponse = app.get( + url = "$APIURL/user/userApi", + headers = mapOf( + "Authorization" to "Bearer ${tokenResponse.token}" + ) + ).parsedSafe() + + if (apiResponse?.ok == false) return false + + setAuthKey( + SubtitleOAuthEntity( + userEmail = useremail, + pass = password, + name = tokenResponse.userData?.username ?: tokenResponse.userData?.name, + accessToken = tokenResponse.token, + apiKey = apiResponse?.apiKey + ) + ) + return true + } + + private fun getAuthKey(): SubtitleOAuthEntity? { + return getKey(accountId, SUBDL_SUBTITLES_USER_KEY) + } + + private fun setAuthKey(data: SubtitleOAuthEntity?) { + if (data == null) removeKey( + accountId, + SUBDL_SUBTITLES_USER_KEY + ) + currentSession = data + setKey(accountId, SUBDL_SUBTITLES_USER_KEY, data) + } + + data class SubtitleOAuthEntity( + @JsonProperty("userEmail") var userEmail: String, + @JsonProperty("pass") var pass: String, + @JsonProperty("name") var name: String? = null, + @JsonProperty("accessToken") var accessToken: String? = null, + @JsonProperty("apiKey") var apiKey: String? = null, + ) + + data class OAuthTokenResponse( + @JsonProperty("token") val token: String? = null, + @JsonProperty("userData") val userData: UserData? = null, + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("message") val message: String? = null, + ) + + data class UserData( + @JsonProperty("email") val email: String, + @JsonProperty("name") val name: String, + @JsonProperty("country") val country: String, + @JsonProperty("scStepCode") val scStepCode: String, + @JsonProperty("scVerified") val scVerified: Boolean, + @JsonProperty("username") val username: String? = null, + @JsonProperty("scUsername") val scUsername: String, + ) + + data class ApiKeyResponse( + @JsonProperty("ok") val ok: Boolean? = false, + @JsonProperty("api_key") val apiKey: String? = null, + @JsonProperty("usage") val usage: Usage? = null, + ) + + data class Usage( + @JsonProperty("total") val total: Long? = 0, + @JsonProperty("today") val today: Long? = 0, + ) + + data class ApiResponse( + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("results") val results: List? = null, + @JsonProperty("subtitles") val subtitles: List? = null, + ) + + data class Result( + @JsonProperty("sd_id") val sdId: Int? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("tmdb_id") val tmdbId: Long? = null, + @JsonProperty("first_air_date") val firstAirDate: String? = null, + @JsonProperty("year") val year: Int? = null, + ) + + data class Subtitle( + @JsonProperty("release_name") val releaseName: String, + @JsonProperty("name") val name: String, + @JsonProperty("lang") val lang: String, + @JsonProperty("author") val author: String? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("subtitlePage") val subtitlePage: String? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("hi") val hearingImpaired: Boolean? = null, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 4ab2e8e2..9150cfc5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -1,16 +1,24 @@ package com.lagradost.cloudstream3.ui -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope.coroutineContext import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -42,7 +50,7 @@ class APIRepository(val api: MainAPI) { private val cache = threadSafeListOf() private var cacheIndex: Int = 0 - const val cacheSize = 20 + const val CACHE_SIZE = 20 } private fun afterPluginsLoaded(forceReload: Boolean) { @@ -86,9 +94,9 @@ class APIRepository(val api: MainAPI) { val add = SavedLoadResponse(unixTime, response, lookingForHash) synchronized(cache) { - if (cache.size > cacheSize) { + if (cache.size > CACHE_SIZE) { cache[cacheIndex] = add // rolling cache - cacheIndex = (cacheIndex + 1) % cacheSize + cacheIndex = (cacheIndex + 1) % CACHE_SIZE } else { cache.add(add) } @@ -174,7 +182,7 @@ class APIRepository(val api: MainAPI) { data: String, isCasting: Boolean, subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit + callback: (ExtractorLink) -> Unit, ): Boolean { if (isInvalidData(data)) return false // this makes providers cleaner return try { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt new file mode 100644 index 00000000..e930961c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -0,0 +1,252 @@ +package com.lagradost.cloudstream3.ui + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import androidx.viewbinding.ViewBinding +import java.util.concurrent.CopyOnWriteArrayList + +open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) { + open fun save(): T? = null + open fun restore(state: T) = Unit + open fun onViewAttachedToWindow() = Unit + open fun onViewDetachedFromWindow() = Unit + open fun onViewRecycled() = Unit +} + + +// Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154 +class StateViewModel : ViewModel() { + val layoutManagerStates = hashMapOf>() +} + +abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter(fragment, 0) + +/** + * BaseAdapter is a persistent state stored adapter that supports headers and footers. + * This should be used for restoring eg scroll or focus related to a view when it is recreated. + * + * Id is a per fragment based unique id used to store the underlying data done in an internal ViewModel. + * + * diffCallback is how the view should be handled when updating, override onUpdateContent for updates + * + * NOTE: + * + * By default it should save automatically, but you can also call save(recycle) + * + * By default no state is stored, but doing an id != 0 will store + * + * By default no headers or footers exist, override footers and headers count + */ +abstract class BaseAdapter< + T : Any, + S : Any>( + fragment: Fragment, + val id: Int = 0, + diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() +) : RecyclerView.Adapter>() { + open val footers: Int = 0 + open val headers: Int = 0 + + fun getItem(position: Int): T { + return mDiffer.currentList[position] + } + + fun getItemOrNull(position: Int): T? { + return mDiffer.currentList.getOrNull(position) + } + + private val mDiffer: AsyncListDiffer = AsyncListDiffer( + object : NonFinalAdapterListUpdateCallback(this) { + override fun onMoved(fromPosition: Int, toPosition: Int) { + super.onMoved(fromPosition + headers, toPosition + headers) + } + + override fun onRemoved(position: Int, count: Int) { + super.onRemoved(position + headers, count) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + super.onChanged(position + headers, count, payload) + } + + override fun onInserted(position: Int, count: Int) { + super.onInserted(position + headers, count) + } + }, + AsyncDifferConfig.Builder(diffCallback).build() + ) + + open fun submitList(list: List?) { + // deep copy at least the top list, because otherwise adapter can go crazy + mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) }) + } + + override fun getItemCount(): Int { + return mDiffer.currentList.size + footers + headers + } + + open fun onUpdateContent(holder: ViewHolderState, item: T, position: Int) = + onBindContent(holder, item, position) + + open fun onBindContent(holder: ViewHolderState, item: T, position: Int) = Unit + open fun onBindFooter(holder: ViewHolderState) = Unit + open fun onBindHeader(holder: ViewHolderState) = Unit + open fun onCreateContent(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateFooter(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + + override fun onViewAttachedToWindow(holder: ViewHolderState) { + holder.onViewAttachedToWindow() + } + + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + holder.onViewDetachedFromWindow() + } + + @Suppress("UNCHECKED_CAST") + fun save(recyclerView: RecyclerView) { + for (child in recyclerView.children) { + val holder = + recyclerView.findContainingViewHolder(child) as? ViewHolderState ?: continue + setState(holder) + } + } + + fun clear() { + stateViewModel.layoutManagerStates[id]?.clear() + } + + @Suppress("UNCHECKED_CAST") + private fun getState(holder: ViewHolderState): S? = + stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S + + private fun setState(holder: ViewHolderState) { + if(id == 0) return + + if (!stateViewModel.layoutManagerStates.contains(id)) { + stateViewModel.layoutManagerStates[id] = HashMap() + } + stateViewModel.layoutManagerStates[id]?.let { map -> + map[holder.absoluteAdapterPosition] = holder.save() + } + } + + private val attachListener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = Unit + override fun onViewDetachedFromWindow(v: View) { + if (v !is RecyclerView) return + save(v) + } + } + + final override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + recyclerView.addOnAttachStateChangeListener(attachListener) + super.onAttachedToRecyclerView(recyclerView) + } + + final override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnAttachStateChangeListener(attachListener) + super.onDetachedFromRecyclerView(recyclerView) + } + + final override fun getItemViewType(position: Int): Int { + if (position < headers) { + return HEADER + } + if (position - headers >= mDiffer.currentList.size) { + return FOOTER + } + + return CONTENT + } + + private val stateViewModel: StateViewModel by fragment.viewModels() + + final override fun onViewRecycled(holder: ViewHolderState) { + setState(holder) + holder.onViewRecycled() + super.onViewRecycled(holder) + } + + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState { + return when (viewType) { + CONTENT -> onCreateContent(parent) + HEADER -> onCreateHeader(parent) + FOOTER -> onCreateFooter(parent) + else -> throw NotImplementedError() + } + } + + // https://medium.com/@domen.lanisnik/efficiently-updating-recyclerview-items-using-payloads-1305f65f3068 + override fun onBindViewHolder( + holder: ViewHolderState, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + return + } + when (getItemViewType(position)) { + CONTENT -> { + val realPosition = position - headers + val item = getItem(realPosition) + onUpdateContent(holder, item, realPosition) + } + + FOOTER -> { + onBindFooter(holder) + } + + HEADER -> { + onBindHeader(holder) + } + } + } + + final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { + when (getItemViewType(position)) { + CONTENT -> { + val realPosition = position - headers + val item = getItem(realPosition) + onBindContent(holder, item, realPosition) + } + + FOOTER -> { + onBindFooter(holder) + } + + HEADER -> { + onBindHeader(holder) + } + } + + getState(holder)?.let { state -> + holder.restore(state) + } + } + + companion object { + private const val HEADER: Int = 1 + private const val FOOTER: Int = 2 + private const val CONTENT: Int = 0 + } +} + +class BaseDiffCallback( + val itemSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() }, + val contentSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() } +) : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem) + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem) + override fun getChangePayload(oldItem: T, newItem: T): Any = Any() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index 46ddce09..1eaac505 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -6,9 +6,10 @@ import android.view.Menu import android.view.View.* import android.widget.* import androidx.appcompat.app.AlertDialog +import androidx.media3.common.util.UnstableApi import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF @@ -23,12 +24,13 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.sortSubs import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.player.LoadType import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo @@ -97,7 +99,7 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { - private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) + private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() init { @@ -262,6 +264,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi var isLoadingMore = false + override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() @@ -294,7 +297,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val generator = RepoLinkGenerator(listOf(epData)) val isSuccessful = safeApiCall { - generator.generateLinks(clearCache = false, isCasting = true, + generator.generateLinks( + clearCache = false, type = LoadType.Chromecast, callback = { it.first?.let { link -> currentLinks.add(link) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt similarity index 68% rename from app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt rename to app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index b4c07792..78ad2a6b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,12 +3,13 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View +import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs -class GrdLayoutManager(val context: Context, _spanCount: Int) : - GridLayoutManager(context, _spanCount) { +class GrdLayoutManager(val context: Context, spanCount: Int) : + GridLayoutManager(context, spanCount) { override fun onFocusSearchFailed( focused: View, focusDirection: Int, @@ -24,7 +25,7 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : } } - override fun onRequestChildFocus( + /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, @@ -32,13 +33,17 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : ): Boolean { // android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams return try { - val pos = maxOf(0, getPosition(focused!!) - 2) - parent.scrollToPosition(pos) + if(focused != null) { + // val pos = maxOf(0, getPosition(focused) - 2) // IDK WHY + val pos = getPosition(focused) + if(pos >= 0) parent.scrollToPosition(pos) + } + super.onRequestChildFocus(parent, state, child, focused) } catch (e: Exception) { false } - } + }*/ // Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d override fun onInterceptFocusSearch(focused: View, direction: Int): View? { @@ -65,32 +70,47 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : val spanCount = this.spanCount val orientation = this.orientation - if (orientation == VERTICAL) { + // fixes arabic by inverting left and right layout focus + val correctDirection = if (this.isLayoutRTL) { when (direction) { + View.FOCUS_RIGHT -> View.FOCUS_LEFT + View.FOCUS_LEFT -> View.FOCUS_RIGHT + else -> direction + } + } else direction + + if (orientation == VERTICAL) { + when (correctDirection) { View.FOCUS_DOWN -> { return spanCount } + View.FOCUS_UP -> { return -spanCount } + View.FOCUS_RIGHT -> { return 1 } + View.FOCUS_LEFT -> { return -1 } } } else if (orientation == HORIZONTAL) { - when (direction) { + when (correctDirection) { View.FOCUS_DOWN -> { return 1 } + View.FOCUS_UP -> { return -1 } + View.FOCUS_RIGHT -> { return spanCount } + View.FOCUS_LEFT -> { return -spanCount } @@ -142,4 +162,32 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att layoutManager = manager } +} + +/** + * Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes. + */ +class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) { + private var biggestObserved: Int = 0 + private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation + private val isHorizontal = orientation == HORIZONTAL + private fun View.updateMaxSize() { + if (isHorizontal) { + this.minimumHeight = biggestObserved + } else { + this.minimumWidth = biggestObserved + } + } + + override fun onChildAttachedToWindow(child: View) { + child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth + if (observed > biggestObserved) { + biggestObserved = observed + children.forEach { it.updateMaxSize() } + } else { + child.updateMaxSize() + } + super.onChildAttachedToWindow(child) + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt index 556ebd34..4879d2e0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt @@ -16,14 +16,16 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatImageView import androidx.core.view.isVisible import com.lagradost.cloudstream3.R -import kotlinx.android.synthetic.main.activity_easter_egg_monke.* -import java.util.* +import com.lagradost.cloudstream3.databinding.ActivityEasterEggMonkeBinding class EasterEggMonke : AppCompatActivity() { + lateinit var binding : ActivityEasterEggMonkeBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_easter_egg_monke) + + binding = ActivityEasterEggMonkeBinding.inflate(layoutInflater) + setContentView(binding.root) val handler = Handler(mainLooper) lateinit var runnable: Runnable @@ -32,15 +34,14 @@ class EasterEggMonke : AppCompatActivity() { handler.postDelayed(runnable, 300) } handler.postDelayed(runnable, 1000) - } private fun shower() { - val containerW = frame.width - val containerH = frame.height - var starW: Float = monke.width.toFloat() - var starH: Float = monke.height.toFloat() + val containerW = binding.frame.width + val containerH = binding.frame.height + var starW: Float = binding.monke.width.toFloat() + var starH: Float = binding.monke.height.toFloat() val newStar = AppCompatImageView(this) val idx = (monkeys.size * Math.random()).toInt() @@ -48,9 +49,9 @@ class EasterEggMonke : AppCompatActivity() { newStar.isVisible = true newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT) - frame.addView(newStar) + binding.frame.addView(newStar) - newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX + newStar.scaleX += Math.random().toFloat() * 1.5f newStar.scaleY = newStar.scaleX starW *= newStar.scaleX starH *= newStar.scaleY @@ -70,7 +71,7 @@ class EasterEggMonke : AppCompatActivity() { set.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - frame.removeView(newStar) + binding.frame.removeView(newStar) } }) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt new file mode 100644 index 00000000..12a5ae2a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt @@ -0,0 +1,39 @@ +package com.lagradost.cloudstream3.ui + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView + + +/** + * ListUpdateCallback that dispatches update events to the given adapter. + * + * @see DiffUtil.DiffResult.dispatchUpdatesTo + */ +open class NonFinalAdapterListUpdateCallback +/** + * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter. + * + * @param mAdapter The Adapter to send updates to. + */(private var mAdapter: RecyclerView.Adapter<*>) : + ListUpdateCallback { + + override fun onInserted(position: Int, count: Int) { + mAdapter.notifyItemRangeInserted(position, count) + } + + override fun onRemoved(position: Int, count: Int) { + mAdapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + mAdapter.notifyItemMoved(fromPosition, toPosition) + } + + @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly + override fun onChanged(position: Int, count: Int, payload: Any?) { + mAdapter.notifyItemRangeChanged(position, count, payload) + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt index eb4eb666..b778ba5a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -13,6 +13,29 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24); companion object { - fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE } -} \ No newline at end of file +} + +enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { + /* + -1 -> None + 0 -> Watching + 1 -> Completed + 2 -> OnHold + 3 -> Dropped + 4 -> PlanToWatch + 5 -> ReWatching + */ + NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24), + WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24), + COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24), + ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24), + DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24), + PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24), + REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24); + + companion object { + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt index 19e24f74..5e2b97e5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -8,24 +8,30 @@ import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import androidx.annotation.OptIn import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.media3.common.util.UnstableApi import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding import com.lagradost.cloudstream3.network.WebViewResolver -import com.lagradost.cloudstream3.utils.AppUtils.loadRepository -import kotlinx.android.synthetic.main.fragment_webview.* +import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository + class WebviewFragment : Fragment() { + + var binding: FragmentWebviewBinding? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } - web_view.webViewClient = object : WebViewClient() { + binding?.webView?.webViewClient = object : WebViewClient() { + @OptIn(UnstableApi::class) override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -40,24 +46,28 @@ class WebviewFragment : Fragment() { return super.shouldOverrideUrlLoading(view, request) } } + binding?.webView?.apply { + WebViewResolver.webViewUserAgent = settings.userAgentString - WebViewResolver.webViewUserAgent = web_view.settings.userAgentString - - web_view.addJavascriptInterface(RepoApi(activity), "RepoApi") - web_view.settings.javaScriptEnabled = true - web_view.settings.userAgentString = USER_AGENT - web_view.settings.domStorageEnabled = true + addJavascriptInterface(RepoApi(activity), "RepoApi") + settings.javaScriptEnabled = true + settings.userAgentString = USER_AGENT + settings.domStorageEnabled = true // WebView.setWebContentsDebuggingEnabled(true) - web_view.loadUrl(url) + loadUrl(url) + } + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { + val localBinding = FragmentWebviewBinding.inflate(inflater, container, false) + binding = localBinding // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_webview, container, false) + return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false) } companion object { @@ -70,7 +80,7 @@ class WebviewFragment : Fragment() { private class RepoApi(val activity: FragmentActivity?) { @JavascriptInterface - fun installRepo(repoUrl: String) { + fun installRepo(repoUrl: String) { activity?.loadRepository(repoUrl) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt new file mode 100644 index 00000000..de0b5c05 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt @@ -0,0 +1,200 @@ +package com.lagradost.cloudstream3.ui.account + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding +import com.lagradost.cloudstream3.databinding.AccountListItemBinding +import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog +import com.lagradost.cloudstream3.ui.result.setImage +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.setImage + +class AccountAdapter( + private val accounts: List, + private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, + private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, + private val accountEditCallback: (DataStoreHelper.Account) -> Unit, + private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit +) : RecyclerView.Adapter() { + + companion object { + const val VIEW_TYPE_SELECT_ACCOUNT = 0 + const val VIEW_TYPE_ADD_ACCOUNT = 1 + const val VIEW_TYPE_EDIT_ACCOUNT = 2 + } + + inner class AccountViewHolder(private val binding: ViewBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(account: DataStoreHelper.Account?) { + when (binding) { + is AccountListItemBinding -> binding.apply { + if (account == null) return@apply + + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = account.name + accountImage.setImage(account.image) + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } else { + root.setOnLongClickListener { + showAccountEditDialog( + context = root.context, + account = account, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } + ) + + true + } + } + + root.setOnClickListener { + accountSelectCallback.invoke(account) + } + } + + is AccountListItemEditBinding -> binding.apply { + if (account == null) return@apply + + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = account.name + accountImage.setImage( + account.image, + fadeIn = false, + radius = 10 + ) + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } + + root.setOnClickListener { + showAccountEditDialog( + context = root.context, + account = account, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } + ) + } + } + + is AccountListItemAddBinding -> binding.apply { + root.setOnClickListener { + val remainingImages = + DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } + .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet() + + val image = + DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random()) + val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 + + val accountName = root.context.getString(R.string.account) + + showAccountEditDialog( + root.context, + DataStoreHelper.Account( + keyIndex = keyIndex, + name = "$accountName $keyIndex", + customImage = null, + defaultImageIndex = image + ), + isNewAccount = true, + accountEditCallback = { account -> accountCreateCallback.invoke(account) }, + accountDeleteCallback = {} + ) + } + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder = + AccountViewHolder( + binding = when (viewType) { + VIEW_TYPE_SELECT_ACCOUNT -> { + AccountListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } + VIEW_TYPE_ADD_ACCOUNT -> { + AccountListItemAddBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } + VIEW_TYPE_EDIT_ACCOUNT -> { + AccountListItemEditBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } + else -> throw IllegalArgumentException("Invalid view type") + } + ) + + override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { + holder.bind(accounts.getOrNull(position)) + } + + var viewType = 0 + + override fun getItemViewType(position: Int): Int { + if (viewType != 0 && position != accounts.count()) { + return viewType + } + + return when (position) { + accounts.count() -> VIEW_TYPE_ADD_ACCOUNT + else -> VIEW_TYPE_SELECT_ACCOUNT + } + } + + override fun getItemCount(): Int { + return accounts.count() + 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt new file mode 100644 index 00000000..d2aca862 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -0,0 +1,356 @@ +package com.lagradost.cloudstream3.ui.account + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.text.Editable +import android.view.LayoutInflater +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding +import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding +import com.lagradost.cloudstream3.databinding.LockPinDialogBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.result.setImage +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod + +object AccountHelper { + fun showAccountEditDialog( + context: Context, + account: DataStoreHelper.Account, + isNewAccount: Boolean, + accountEditCallback: (DataStoreHelper.Account) -> Unit, + accountDeleteCallback: (DataStoreHelper.Account) -> Unit + ) { + val binding = AccountEditDialogBinding.inflate(LayoutInflater.from(context), null, false) + val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setView(binding.root) + + var currentEditAccount = account + val dialog = builder.show() + + if (!isNewAccount) binding.title.setText(R.string.edit_account) + + // Set up the dialog content + binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name) + binding.accountName.doOnTextChanged { text, _, _, _ -> + currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "") + } + + binding.deleteBtt.isGone = isNewAccount + binding.deleteBtt.setOnClickListener { + val dialogClickListener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + accountDeleteCallback.invoke(account) + dialog?.dismissSafe() + } + + DialogInterface.BUTTON_NEGATIVE -> { + dialog?.dismissSafe() + } + } + } + + try { + AlertDialog.Builder(context).setTitle(R.string.delete).setMessage( + context.getString(R.string.delete_message).format( + currentEditAccount.name + ) + ) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (t: Throwable) { + logError(t) + } + } + + binding.cancelBtt.setOnClickListener { + dialog?.dismissSafe() + } + + // Handle the profile picture and its interactions + binding.accountImage.setImage(account.image) + binding.accountImage.setOnClickListener { + // Roll the image forwards once + currentEditAccount = + currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) + binding.accountImage.setImage(currentEditAccount.image) + } + + // Handle applying changes + binding.applyBtt.setOnClickListener { + if (currentEditAccount.lockPin != null) { + // Ask for the current PIN + showPinInputDialog(context, currentEditAccount.lockPin, false) { pin -> + if (pin == null) return@showPinInputDialog + // PIN is correct, proceed to update the account + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() + } + } else { + // No lock PIN set, proceed to update the account + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() + } + } + + // Handle setting or changing the PIN + if (currentEditAccount.keyIndex == getDefaultAccount(context).keyIndex) { + binding.lockProfileCheckbox.isVisible = false + if (currentEditAccount.lockPin != null) { + currentEditAccount = currentEditAccount.copy(lockPin = null) + } + } + + var canSetPin = true + + binding.lockProfileCheckbox.isChecked = currentEditAccount.lockPin != null + + binding.lockProfileCheckbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + if (canSetPin) { + showPinInputDialog(context, null, true) { pin -> + if (pin == null) { + binding.lockProfileCheckbox.isChecked = false + return@showPinInputDialog + } + + currentEditAccount = currentEditAccount.copy(lockPin = pin) + } + } + } else { + if (currentEditAccount.lockPin != null) { + // Ask for the current PIN + showPinInputDialog(context, currentEditAccount.lockPin, true) { pin -> + if (pin == null || pin != currentEditAccount.lockPin) { + canSetPin = false + binding.lockProfileCheckbox.isChecked = true + } else { + currentEditAccount = currentEditAccount.copy(lockPin = null) + } + } + } + } + } + + canSetPin = true + } + + fun showPinInputDialog( + context: Context, + currentPin: String?, + editAccount: Boolean, + forStartup: Boolean = false, + errorText: String? = null, + callback: (String?) -> Unit + ) { + fun TextView.visibleWithText(@StringRes textRes: Int) { + isVisible = true + setText(textRes) + } + + fun TextView.visibleWithText(text: String?) { + isVisible = true + setText(text) + } + + val binding = LockPinDialogBinding.inflate(LayoutInflater.from(context)) + + val isPinSet = currentPin != null + val isNewPin = editAccount && !isPinSet + val isEditPin = editAccount && isPinSet + + val titleRes = if (isEditPin) R.string.enter_current_pin else R.string.enter_pin + + var isPinValid = false + + val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setView(binding.root) + .setTitle(titleRes) + .setNegativeButton(R.string.cancel) { _, _ -> + callback.invoke(null) + } + .setOnCancelListener { + callback.invoke(null) + } + .setOnDismissListener { + if (!isPinValid) { + callback.invoke(null) + } + } + + if (forStartup) { + val currentAccount = DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } + + builder.setTitle(context.getString(R.string.enter_pin_with_name, currentAccount?.name)) + builder.setOnDismissListener { + if (!isPinValid) { + context.getActivity()?.finish() + } + } + // So that if they don't know the PIN for the current account, + // they don't get completely locked out + builder.setNeutralButton(R.string.use_default_account) { _, _ -> + val activity = context.getActivity() + if (activity is AccountSelectActivity) { + isPinValid = true + activity.viewModel.handleAccountSelect(getDefaultAccount(context), activity) + } + } + } + + if (isNewPin) { + if (errorText != null) binding.pinEditTextError.visibleWithText(errorText) + builder.setPositiveButton(R.string.setup_done) { _, _ -> + if (!isPinValid) { + // If the done button is pressed and there is an error, + // ask again, and mention the error that caused this. + showPinInputDialog( + context = binding.root.context, + currentPin = null, + editAccount = true, + errorText = binding.pinEditTextError.text.toString(), + callback = callback + ) + } else { + val enteredPin = binding.pinEditText.text.toString() + callback.invoke(enteredPin) + } + } + } + + val dialog = builder.create() + + binding.pinEditText.doOnTextChanged { text, _, _, _ -> + val enteredPin = text.toString() + val isEnteredPinValid = enteredPin.length == 4 + + if (isEnteredPinValid) { + if (isPinSet) { + if (enteredPin != currentPin) { + binding.pinEditTextError.visibleWithText(R.string.pin_error_incorrect) + binding.pinEditText.text = null + isPinValid = false + } else { + binding.pinEditTextError.isVisible = false + isPinValid = true + + callback.invoke(enteredPin) + dialog.dismissSafe() + } + } else { + binding.pinEditTextError.isVisible = false + isPinValid = true + } + } else if (isNewPin) { + binding.pinEditTextError.visibleWithText(R.string.pin_error_length) + isPinValid = false + } + } + + // Detect IME_ACTION_DONE + binding.pinEditText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE && isPinValid) { + val enteredPin = binding.pinEditText.text.toString() + callback.invoke(enteredPin) + dialog.dismissSafe() + } + true + } + + // We don't want to accidentally have the dialog dismiss when clicking outside of it. + // That is what the cancel button is for. + dialog.setCanceledOnTouchOutside(false) + + dialog.show() + + // Auto focus on PIN input and show keyboard + binding.pinEditText.requestFocus() + binding.pinEditText.postDelayed({ + showInputMethod(binding.pinEditText) + }, 200) + } + + fun Activity?.showAccountSelectLinear() { + val activity = this as? MainActivity ?: return + val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java] + + val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate( + LayoutInflater.from(activity) + ) + + val builder = BottomSheetDialog(activity) + builder.setContentView(binding.root) + builder.show() + + binding.manageAccountsButton.setOnClickListener { + val accountSelectIntent = Intent(activity, AccountSelectActivity::class.java) + accountSelectIntent.putExtra("isEditingFromMainActivity", true) + activity.startActivity(accountSelectIntent) + builder.dismissSafe() + } + + val recyclerView: RecyclerView = binding.accountRecyclerView + + val itemSize = recyclerView.resources.getDimensionPixelSize( + R.dimen.account_select_linear_item_size + ) + + recyclerView.addItemDecoration(AccountSelectLinearItemDecoration(itemSize)) + + recyclerView.setLinearListLayout(isHorizontal = true) + + val currentAccount = DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } ?: getDefaultAccount(activity) + + // We want to make sure the accounts are up-to-date + viewModel.handleAccountSelect( + currentAccount, + activity, + reloadForActivity = true + ) + + activity.observe(viewModel.accounts) { liveAccounts -> + recyclerView.adapter = AccountAdapter( + liveAccounts, + accountSelectCallback = { account -> + viewModel.handleAccountSelect(account, activity) + builder.dismissSafe() + }, + accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, + accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, + accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } + ) + + activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> + // Scroll to current account (which is focused by default) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt new file mode 100644 index 00000000..0da69f9c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -0,0 +1,199 @@ +package com.lagradost.cloudstream3.ui.account + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CommonActivity.loadThemes +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.ActivityAccountSelectBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_EDIT_ACCOUNT +import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_SELECT_ACCOUNT +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.BiometricAuthenticator +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication +import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts +import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex +import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute + +class AccountSelectActivity : AppCompatActivity(), BiometricCallback { + + lateinit var viewModel: AccountViewModel + + @SuppressLint("NotifyDataSetChanged") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + loadThemes(this) + + window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground) + + // Are we editing and coming from MainActivity? + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + false + ) + + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false + ) || accounts.count() <= 1 + + viewModel = ViewModelProvider(this)[AccountViewModel::class.java] + + fun askBiometricAuth() { + + if (isLayout(PHONE) && isAuthEnabled(this)) { + if (deviceHasPasswordPinLock(this)) { + startBiometricAuthentication( + this, + R.string.biometric_authentication_title, + false + ) + + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + } + } + } + + observe(viewModel.isAllowedLogin) { isAllowedLogin -> + if (isAllowedLogin) { + // We are allowed to continue to MainActivity + navigateToMainActivity() + } + } + + // Don't show account selection if there is only + // one account that exists + if (!isEditingFromMainActivity && skipStartup) { + val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex } + if (currentAccount?.lockPin != null) { + CommonActivity.init(this) + viewModel.handleAccountSelect(currentAccount, this, true) + } else { + if (accounts.count() > 1) { + showToast(this, getString( + R.string.logged_account, + currentAccount?.name + )) + } + + navigateToMainActivity() + } + + return + } + + CommonActivity.init(this) + + val binding = ActivityAccountSelectBinding.inflate(layoutInflater) + setContentView(binding.root) + + val recyclerView: AutofitRecyclerView = binding.accountRecyclerView + + observe(viewModel.accounts) { liveAccounts -> + val adapter = AccountAdapter( + liveAccounts, + // Handle the selected account + accountSelectCallback = { + viewModel.handleAccountSelect(it, this) + }, + accountCreateCallback = { viewModel.handleAccountUpdate(it, this) }, + accountEditCallback = { + viewModel.handleAccountUpdate(it, this) + + // We came from MainActivity, return there + // and switch to the edited account + if (isEditingFromMainActivity) { + setAccount(it) + navigateToMainActivity() + } + }, + accountDeleteCallback = { viewModel.handleAccountDelete(it,this) } + ) + + recyclerView.adapter = adapter + + if (isLayout(TV or EMULATOR)) { + binding.editAccountButton.setBackgroundResource( + R.drawable.player_button_tv_attr_no_bg + ) + } + + observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> + // Scroll to current account (which is focused by default) + val layoutManager = recyclerView.layoutManager as GridLayoutManager + layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) + } + + observe(viewModel.isEditing) { isEditing -> + if (isEditing) { + binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24) + binding.title.setText(R.string.manage_accounts) + adapter.viewType = VIEW_TYPE_EDIT_ACCOUNT + } else { + binding.editAccountButton.setImageResource(R.drawable.ic_baseline_edit_24) + binding.title.setText(R.string.select_an_account) + adapter.viewType = VIEW_TYPE_SELECT_ACCOUNT + } + + adapter.notifyDataSetChanged() + } + + if (isEditingFromMainActivity) { + viewModel.setIsEditing(true) + } + + binding.editAccountButton.setOnClickListener { + // We came from MainActivity, return there + // and resume its state + if (isEditingFromMainActivity) { + navigateToMainActivity() + return@setOnClickListener + } + + viewModel.toggleIsEditing() + } + + if (isLayout(TV or EMULATOR)) { + recyclerView.spanCount = if (liveAccounts.count() + 1 <= 6) { + liveAccounts.count() + 1 + } else 6 + } + } + + askBiometricAuth() + } + + private fun navigateToMainActivity() { + val mainIntent = Intent(this, MainActivity::class.java) + startActivity(mainIntent) + finish() // Finish the account selection activity + } + + override fun onAuthenticationSuccess() { + Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity") + } + + override fun onAuthenticationError() { + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt new file mode 100644 index 00000000..eb907b34 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.ui.account + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class AccountSelectLinearItemDecoration(private val size: Int) : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val layoutParams = view.layoutParams as RecyclerView.LayoutParams + layoutParams.width = size + layoutParams.height = size + view.layoutParams = layoutParams + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt new file mode 100644 index 00000000..14559607 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt @@ -0,0 +1,123 @@ +package com.lagradost.cloudstream3.ui.account + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys +import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts +import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount +import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount + +class AccountViewModel : ViewModel() { + private fun getAllAccounts(): List { + return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList() + } + + private val _accounts: MutableLiveData> = MutableLiveData(getAllAccounts()) + val accounts: LiveData> = _accounts + + private val _isEditing = MutableLiveData(false) + val isEditing: LiveData = _isEditing + + private val _isAllowedLogin = MutableLiveData(false) + val isAllowedLogin: LiveData = _isAllowedLogin + + private val _selectedKeyIndex = MutableLiveData( + getAllAccounts().indexOfFirst { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } + ) + val selectedKeyIndex: LiveData = _selectedKeyIndex + + fun setIsEditing(value: Boolean) { + _isEditing.postValue(value) + } + + fun toggleIsEditing() { + _isEditing.postValue(!(_isEditing.value ?: false)) + } + + fun handleAccountUpdate( + account: DataStoreHelper.Account, + context: Context + ) { + val currentAccounts = getAccounts(context).toMutableList() + + val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex } + + if (overrideIndex != -1) { + currentAccounts[overrideIndex] = account + } else currentAccounts.add(account) + + val currentHomePage = DataStoreHelper.currentHomePage + + setAccount(account) + + DataStoreHelper.currentHomePage = currentHomePage + DataStoreHelper.accounts = currentAccounts.toTypedArray() + + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + } + + fun handleAccountDelete( + account: DataStoreHelper.Account, + context: Context + ) { + removeKeys(account.keyIndex.toString()) + + val currentAccounts = getAccounts(context).toMutableList() + + currentAccounts.removeIf { it.keyIndex == account.keyIndex } + + DataStoreHelper.accounts = currentAccounts.toTypedArray() + + if (account.keyIndex == DataStoreHelper.selectedKeyIndex) { + setAccount(getDefaultAccount(context)) + } + + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAllAccounts().indexOfFirst { + it.keyIndex == DataStoreHelper.selectedKeyIndex + }) + } + + fun handleAccountSelect( + account: DataStoreHelper.Account, + context: Context, + forStartup: Boolean = false, + reloadForActivity: Boolean = false + ) { + if (reloadForActivity) { + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + return + } + + // Check if the selected account has a lock PIN set + if (account.lockPin != null) { + // The selected account has a PIN set, prompt the user to enter the PIN + showPinInputDialog( + context, + account.lockPin, + false, + forStartup + ) { pin -> + if (pin == null) return@showPinInputDialog + // Pin is correct, proceed + _isAllowedLogin.postValue(true) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + setAccount(account) + } + } else { + // No PIN set for the selected account, proceed + _isAllowedLogin.postValue(true) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + setAccount(account) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt new file mode 100644 index 00000000..d211cb87 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -0,0 +1,414 @@ +package com.lagradost.cloudstream3.ui.download + +import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.CheckBox +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding +import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.VideoDownloadHelper + +const val DOWNLOAD_ACTION_PLAY_FILE = 0 +const val DOWNLOAD_ACTION_DELETE_FILE = 1 +const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 +const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 +const val DOWNLOAD_ACTION_DOWNLOAD = 4 +const val DOWNLOAD_ACTION_LONG_CLICK = 5 + +const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 +const val DOWNLOAD_ACTION_LOAD_RESULT = 1 + +sealed class VisualDownloadCached { + abstract val currentBytes: Long + abstract val totalBytes: Long + abstract val data: VideoDownloadHelper.DownloadCached + abstract var isSelected: Boolean + + data class Child( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: VideoDownloadHelper.DownloadEpisodeCached, + override var isSelected: Boolean, + ) : VisualDownloadCached() + + data class Header( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: VideoDownloadHelper.DownloadHeaderCached, + override var isSelected: Boolean, + val child: VideoDownloadHelper.DownloadEpisodeCached?, + val currentOngoingDownloads: Int, + val totalDownloads: Int, + ) : VisualDownloadCached() +} + +data class DownloadClickEvent( + val action: Int, + val data: VideoDownloadHelper.DownloadEpisodeCached +) + +data class DownloadHeaderClickEvent( + val action: Int, + val data: VideoDownloadHelper.DownloadHeaderCached +) + +class DownloadAdapter( + private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, + private val onItemClickEvent: (DownloadClickEvent) -> Unit, + private val onItemSelectionChanged: (Int, Boolean) -> Unit, +) : ListAdapter(DiffCallback()) { + + private var isMultiDeleteState: Boolean = false + + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_CHILD = 1 + } + + inner class DownloadViewHolder( + private val binding: ViewBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(card: VisualDownloadCached?) { + when (binding) { + is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header) + is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child) + } + } + + private fun bindHeader(card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + episodeHolder.apply { + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } + + downloadHeaderPoster.apply { + setImage(data.poster) + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } else { + setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_LOAD_RESULT, + data + ) + ) + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } + downloadHeaderTitle.text = data.name + val formattedSize = formatShortFileSize(itemView.context, card.totalBytes) + + if (card.child != null) { + handleChildDownload(card, formattedSize) + } else handleParentDownload(card, formattedSize) + + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleChildDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + card.child ?: return + downloadHeaderGotoChild.isVisible = false + + val posDur = getViewPos(card.data.id) + downloadHeaderEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() + } + } + + val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadHeaderInfo.text = formattedSize + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + downloadButton.resetView() + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) + downloadButton.isVisible = !isMultiDeleteState + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + card.child + ) + ) + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleParentDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.text = + downloadHeaderInfo.context.getString(R.string.extra_info_format).format( + card.totalDownloads, + downloadHeaderInfo.context.resources.getQuantityString( + R.plurals.episodes, + card.totalDownloads + ), + formattedSize + ) + } catch (e: Exception) { + downloadHeaderInfo.text = null + logError(e) + } + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_GO_TO_CHILD, + card.data + ) + ) + } + } + } + + private fun bindChild(card: VisualDownloadCached.Child?) { + if (binding !is DownloadChildEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + val posDur = getViewPos(data.id) + downloadChildEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() + } + } + + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadChildEpisodeTextExtra.text = + formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + downloadButton.resetView() + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + onItemClickEvent + ) + downloadButton.isVisible = !isMultiDeleteState + + downloadChildEpisodeText.apply { + text = context.getNameFull(data.name, data.episode, data.season) + isSelected = true // Needed for text repeating + } + + downloadChildEpisodeHolder.setOnClickListener { + onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + } + + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } + + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data + ) + ) + } + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } + + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = when (viewType) { + VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) + VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) + else -> throw IllegalArgumentException("Invalid view type") + } + return DownloadViewHolder(binding) + } + + override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is VisualDownloadCached.Child -> VIEW_TYPE_CHILD + is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + else -> throw IllegalArgumentException("Invalid data type at position $position") + } + } + + fun setIsMultiDeleteState(value: Boolean) { + if (isMultiDeleteState == value) return + isMultiDeleteState = value + notifyItemRangeChanged(0, itemCount) + } + + fun notifyAllSelected() { + currentList.indices.forEach { index -> + if (!currentList[index].isSelected) { + notifyItemChanged(index) + } + } + } + + fun notifySelectionStates() { + currentList.indices.forEach { index -> + if (currentList[index].isSelected) { + notifyItemChanged(index) + } + } + } + + private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { + val isChecked = !checkbox.isChecked + checkbox.isChecked = isChecked + onItemSelectionChanged.invoke(itemId, isChecked) + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { + return oldItem.data.id == newItem.data.id + } + + override fun areContentsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index add36f1a..494e82e5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -1,27 +1,30 @@ package com.lagradost.cloudstream3.ui.download -import android.app.Activity import android.content.DialogInterface -import android.widget.Toast +import android.net.Uri import androidx.appcompat.app.AlertDialog +import com.google.android.material.snackbar.Snackbar import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator +import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadManager +import kotlinx.coroutines.MainScope object DownloadButtonSetup { - fun handleDownloadClick(activity: Activity?, click: DownloadClickEvent) { + fun handleDownloadClick(click: DownloadClickEvent) { val id = click.data.id - if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return when (click.action) { DOWNLOAD_ACTION_DELETE_FILE -> { activity?.let { ctx -> @@ -30,9 +33,15 @@ object DownloadButtonSetup { DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { - VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id) + VideoDownloadManager.deleteFilesAndUpdateSettings( + ctx, + setOf(id), + MainScope() + ) } + DialogInterface.BUTTON_NEGATIVE -> { + // Do nothing on cancel } } } @@ -57,11 +66,13 @@ object DownloadButtonSetup { } } } + DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause) ) } + DOWNLOAD_ACTION_RESUME_DOWNLOAD -> { activity?.let { ctx -> if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) { @@ -80,6 +91,7 @@ object DownloadButtonSetup { } } } + DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = @@ -89,64 +101,80 @@ object DownloadButtonSetup { )?.fileLength ?: 0 if (length > 0) { - showToast(act, R.string.delete, Toast.LENGTH_LONG) - } else { - showToast(act, R.string.download, Toast.LENGTH_LONG) + showSnackbar( + act, + R.string.offline_file, + Snackbar.LENGTH_LONG + ) } } } + DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val info = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - act, - click.data.id - ) ?: return - val keyInfo = getKey( - VideoDownloadManager.KEY_DOWNLOAD_INFO, - click.data.id.toString() - ) ?: return val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return - act.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator( - listOf( - ExtractorUri( - uri = info.path, + val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) + ?.mapNotNull { + getKey(it) + } + ?.filter { it.parentId == click.data.parentId } - id = click.data.id, - parentId = click.data.parentId, - name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName - season = click.data.season, - episode = click.data.episode, - headerName = parent.name, - tvType = parent.type, + val currentSeason = click.data.season ?: 0 + val currentEpisode = click.data.episode - basePath = keyInfo.basePath, - displayName = keyInfo.displayName, - relativePath = keyInfo.relativePath, - ) - ) + val items = mutableListOf() + + // Make sure we only get this episode and episodes after it, + // and that we can go to the next season if we need to. + val allRelevantEpisodes = episodes + ?.sortedWith( + compareByDescending { it.id == click.data.id } + .thenBy { it.season ?: 0 } + .thenBy { it.episode } + ) + ?.filter { + if (it.season == null) return@filter true + val isCurrentOrLaterInSeason = it.season == currentSeason && (it.episode >= currentEpisode || it.id == click.data.id) + val isInFutureSeasons = it.season > currentSeason + + isCurrentOrLaterInSeason || isInFutureSeasons + } + + allRelevantEpisodes?.forEach { + val keyInfo = getKey( + VideoDownloadManager.KEY_DOWNLOAD_INFO, + it.id.toString() + ) ?: return@forEach + + items.add( + ExtractorUri( + // We just use a temporary placeholder for the URI, + // it will be updated in generateLinks(). + // We just do this for performance since getting + // all paths at once can be quite expensive. + uri = Uri.EMPTY, + id = it.id, + parentId = it.parentId, + name = act.getString(R.string.downloaded_file), + season = it.season, + episode = it.episode, + headerName = parent.name, + tvType = parent.type, + basePath = keyInfo.basePath, + displayName = keyInfo.displayName, + relativePath = keyInfo.relativePath, ) ) - //R.id.global_to_navigation_player, PlayerFragment.newInstance( - // UriData( - // info.path.toString(), - // keyInfo.basePath, - // keyInfo.relativePath, - // keyInfo.displayName, - // click.data.parentId, - // click.data.id, - // headerName ?: "null", - // if (click.data.episode <= 0) null else click.data.episode, - // click.data.season - // ), - // getViewPos(click.data.id)?.position ?: 0 - //) + } + + act.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + DownloadFileGenerator(items) + ) ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonViewHolder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonViewHolder.kt deleted file mode 100644 index 0096ff42..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonViewHolder.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -interface DownloadButtonViewHolder { - var downloadButton : EasyDownloadButton - fun reattachDownloadButton() -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt deleted file mode 100644 index a541171b..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.cardview.widget.CardView -import androidx.core.widget.ContentLoadingProgressBar -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import kotlinx.android.synthetic.main.download_child_episode.view.* -import java.util.* - -const val DOWNLOAD_ACTION_PLAY_FILE = 0 -const val DOWNLOAD_ACTION_DELETE_FILE = 1 -const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 -const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 -const val DOWNLOAD_ACTION_DOWNLOAD = 4 -const val DOWNLOAD_ACTION_LONG_CLICK = 5 - -data class VisualDownloadChildCached( - val currentBytes: Long, - val totalBytes: Long, - val data: VideoDownloadHelper.DownloadEpisodeCached, -) - -data class DownloadClickEvent(val action: Int, val data: EasyDownloadButton.IMinimumData) - -class DownloadChildAdapter( - var cardList: List, - private val clickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { - - private val mBoundViewHolders: HashSet = HashSet() - private fun getAllBoundViewHolders(): Set? { - return Collections.unmodifiableSet(mBoundViewHolders) - } - - fun killAdapter() { - getAllBoundViewHolders()?.forEach { view -> - view?.downloadButton?.dispose() - } - } - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - } - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - mBoundViewHolders.remove(holder) - } - } - - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.reattachDownloadButton() - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return DownloadChildViewHolder( - LayoutInflater.from(parent.context).inflate(R.layout.download_child_episode, parent, false), - clickCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is DownloadChildViewHolder -> { - holder.bind(cardList[position]) - mBoundViewHolders.add(holder) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - class DownloadChildViewHolder - constructor( - itemView: View, - private val clickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { - override var downloadButton = EasyDownloadButton() - - private val title: TextView = itemView.download_child_episode_text - private val extraInfo: TextView = itemView.download_child_episode_text_extra - private val holder: CardView = itemView.download_child_episode_holder - private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress - private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded - private val downloadImage: ImageView = itemView.download_child_episode_download - - private var localCard: VisualDownloadChildCached? = null - - fun bind(card: VisualDownloadChildCached) { - localCard = card - val d = card.data - - val posDur = getViewPos(d.id) - if (posDur != null) { - val visualPos = posDur.fixVisual() - progressBar.max = (visualPos.duration / 1000).toInt() - progressBar.progress = (visualPos.position / 1000).toInt() - progressBar.visibility = View.VISIBLE - } else { - progressBar.visibility = View.GONE - } - - title.text = title.context.getNameFull(d.name, d.episode, d.season) - title.isSelected = true // is needed for text repeating - - downloadButton.setUpButton( - card.currentBytes, - card.totalBytes, - progressBarDownload, - downloadImage, - extraInfo, - card.data, - clickCallback - ) - - holder.setOnClickListener { - clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d)) - } - } - - override fun reattachDownloadButton() { - downloadButton.dispose() - val card = localCard - if (card != null) { - downloadButton.setUpButton( - card.currentBytes, - card.totalBytes, - progressBarDownload, - downloadImage, - extraInfo, - card.data, - clickCallback - ) - } - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt index 477a18e0..09c48a04 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -1,27 +1,35 @@ package com.lagradost.cloudstream3.ui.download import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding +import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.android.synthetic.main.fragment_child_downloads.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV class DownloadChildFragment : Fragment() { + private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentChildDownloadsBinding? = null + companion object { - fun newInstance(headerName: String, folder: String) : Bundle { + fun newInstance(headerName: String, folder: String): Bundle { return Bundle().apply { putString("folder", folder) putString("name", headerName) @@ -30,77 +38,170 @@ class DownloadChildFragment : Fragment() { } override fun onDestroyView() { - (download_child_list?.adapter as DownloadChildAdapter?)?.killAdapter() - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } + detachBackPressedCallback() + binding = null super.onDestroyView() } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return inflater.inflate(R.layout.fragment_child_downloads, container, false) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - private fun updateList(folder: String) = main { - context?.let { ctx -> - val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) } - val eps = withContext(Dispatchers.IO) { - data.mapNotNull { key -> - context?.getKey(key) - }.mapNotNull { - val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id) - ?: return@mapNotNull null - VisualDownloadChildCached(info.fileLength, info.totalBytes, it) - } - }.sortedBy { it.data.episode + (it.data.season?: 0)*100000 } - if (eps.isEmpty()) { - activity?.onBackPressed() - return@main - } - - (download_child_list?.adapter as DownloadChildAdapter? ?: return@main).cardList = eps - download_child_list?.adapter?.notifyDataSetChanged() - } - } - - private var downloadDeleteEventListener: ((Int) -> Unit)? = null - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) + } + + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() + val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - activity?.onBackPressed() // TODO FIX + activity?.onBackPressedDispatcher?.onBackPressed() return } - context?.fixPaddingStatusbar(download_child_root) - download_child_toolbar.title = name - download_child_toolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - download_child_toolbar.setNavigationOnClickListener { - activity?.onBackPressed() - } - - val adapter: RecyclerView.Adapter = - DownloadChildAdapter( - ArrayList(), - ) { click -> - handleDownloadClick(activity, click) - } - - downloadDeleteEventListener = { id: Int -> - val list = (download_child_list?.adapter as DownloadChildAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - updateList(folder) + binding?.downloadChildToolbar?.apply { + title = name + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() } } + setAppBarNoScrollFlagsOnTV() } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - download_child_list.adapter = adapter - download_child_list.layoutManager = GridLayoutManager(context, 1) + observe(downloadsViewModel.childCards) { + if (it.isEmpty()) { + activity?.onBackPressedDispatcher?.onBackPressed() + return@observe + } - updateList(folder) + (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it) + } + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { + detachBackPressedCallback() + downloadsViewModel.clearSelectedItems() + binding?.downloadChildToolbar?.isVisible = true + } + } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) + + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() + + val allSelected = downloadsViewModel.isAllSelected() + if (allSelected) { + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) + } + + val adapter = DownloadAdapter( + {}, + { click -> + if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { + context?.let { ctx -> + downloadsViewModel.handleSingleDelete(ctx, click.data.id) + } + } else handleDownloadClick(click) + }, + { itemId, isChecked -> + if (isChecked) { + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) + } + ) + + binding?.downloadChildList?.apply { + setHasFixedSize(true) + setItemViewCacheSize(20) + this.adapter = adapter + setLinearListLayout( + isHorizontal = false, + nextRight = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + } + + context?.let { downloadsViewModel.updateChildList(it, folder) } + fixPaddingStatusbar(binding?.downloadChildRoot) + } + + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadChildToolbar?.isVisible = false + activity?.attachBackPressedCallback { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) + } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index f0340845..447b4f13 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -1,53 +1,62 @@ package com.lagradost.cloudstream3.ui.download +import android.app.Activity import android.app.Dialog import android.content.ClipboardManager import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import android.widget.TextView import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.isMovieType +import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding +import com.lagradost.cloudstream3.databinding.StreamInputBinding +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.android.synthetic.main.fragment_downloads.* -import kotlinx.android.synthetic.main.stream_input.* -import android.text.format.Formatter.formatShortFileSize -import androidx.core.widget.doOnTextChanged -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import java.net.URI - const val DOWNLOAD_NAVIGATE_TO = "downloadpage" class DownloadFragment : Fragment() { private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentDownloadsBinding? = null private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -58,19 +67,9 @@ class DownloadFragment : Fragment() { this.layoutParams = param } - private fun setList(list: List) { - main { - (download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list - download_list?.adapter?.notifyDataSetChanged() - } - } - override fun onDestroyView() { - if (downloadDeleteEventListener != null) { - VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!! - downloadDeleteEventListener = null - } - (download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter() + detachBackPressedCallback() + binding = null super.onDestroyView() } @@ -78,181 +77,315 @@ class DownloadFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - downloadsViewModel = - ViewModelProvider(this)[DownloadViewModel::class.java] - - return inflater.inflate(R.layout.fragment_downloads, container, false) + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - private var downloadDeleteEventListener: ((Int) -> Unit)? = null - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) hideKeyboard() + binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - observe(downloadsViewModel.noDownloadsText) { - text_no_downloads.text = it + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) } + + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() + observe(downloadsViewModel.headerCards) { - setList(it) - download_loading.isVisible = false + (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it) + binding?.downloadLoading?.isVisible = false + binding?.textNoDownloads?.isVisible = it.isEmpty() } observe(downloadsViewModel.availableBytes) { - download_free_txt?.text = - getString(R.string.storage_size_format).format( - getString(R.string.free_storage), - formatShortFileSize(view.context, it) - ) - download_free?.setLayoutWidth(it) + updateStorageInfo( + view.context, + it, + R.string.free_storage, + binding?.downloadFreeTxt, + binding?.downloadFree + ) } observe(downloadsViewModel.usedBytes) { - download_used_txt?.text = - getString(R.string.storage_size_format).format( - getString(R.string.used_storage), - formatShortFileSize(view.context, it) - ) - download_used?.setLayoutWidth(it) - download_storage_appbar?.isVisible = it > 0 - } - observe(downloadsViewModel.downloadBytes) { - download_app_txt?.text = - getString(R.string.storage_size_format).format( - getString(R.string.app_storage), - formatShortFileSize(view.context, it) - ) - download_app?.setLayoutWidth(it) - } - - val adapter: RecyclerView.Adapter = - DownloadHeaderAdapter( - ArrayList(), - { click -> - when (click.action) { - 0 -> { - if (click.data.type.isMovieType()) { - //wont be called - } else { - val folder = DataStore.getFolderName( - DOWNLOAD_EPISODE_CACHE, - click.data.id.toString() - ) - activity?.navigate( - R.id.action_navigation_downloads_to_navigation_download_child, - DownloadChildFragment.newInstance(click.data.name, folder) - ) - } - } - 1 -> { - (activity as AppCompatActivity?)?.loadResult( - click.data.url, - click.data.apiName - ) - } - } - - }, - { downloadClickEvent -> - if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter - handleDownloadClick(activity, downloadClickEvent) - if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { - context?.let { ctx -> - downloadsViewModel.updateList(ctx) - } - } - } + updateStorageInfo( + view.context, + it, + R.string.used_storage, + binding?.downloadUsedTxt, + binding?.downloadUsed ) - downloadDeleteEventListener = { id -> - val list = (download_list?.adapter as DownloadHeaderAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - context?.let { ctx -> - setList(ArrayList()) - downloadsViewModel.updateList(ctx) - } + // Prevent race condition and make sure + // we don't display it early + if ( + downloadsViewModel.isMultiDeleteState.value == null || + downloadsViewModel.isMultiDeleteState.value == false + ) binding?.downloadStorageAppbar?.isVisible = it > 0 + } + observe(downloadsViewModel.downloadBytes) { + updateStorageInfo( + view.context, + it, + R.string.app_storage, + binding?.downloadAppTxt, + binding?.downloadApp + ) + } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + } + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { + detachBackPressedCallback() + downloadsViewModel.clearSelectedItems() + // Prevent race condition and make sure + // we don't display it early + if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) { + binding?.downloadStorageAppbar?.isVisible = true } } } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() - download_list?.adapter = adapter - download_list?.layoutManager = GridLayoutManager(context, 1) + val allSelected = downloadsViewModel.isAllSelected() + if (allSelected) { + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) + } - // Should be visible in emulator layout - download_stream_button?.isGone = isTrueTvSettings() - download_stream_button?.setOnClickListener { - val dialog = - Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom) - dialog.setContentView(R.layout.stream_input) - - dialog.show() - - // If user has clicked the switch do not interfere - var preventAutoSwitching = false - dialog.hls_switch?.setOnClickListener { - preventAutoSwitching = true + val adapter = DownloadAdapter( + { click -> handleItemClick(click) }, + { click -> + if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { + context?.let { ctx -> + downloadsViewModel.handleSingleDelete(ctx, click.data.id) + } + } else handleDownloadClick(click) + }, + { itemId, isChecked -> + if (isChecked) { + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } + ) - fun activateSwitchOnHls(text: String?) { - dialog.hls_switch?.isChecked = normalSafeApiCall { - URI(text).path?.substringAfterLast(".")?.contains("m3u") - } == true + binding?.downloadList?.apply { + setHasFixedSize(true) + setItemViewCacheSize(20) + this.adapter = adapter + setLinearListLayout( + isHorizontal = false, + nextRight = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + } + + binding?.apply { + openLocalVideoButton.apply { + isGone = isLayout(TV) + setOnClickListener { openLocalVideo() } } - - dialog.stream_referer?.doOnTextChanged { text, _, _, _ -> - if (!preventAutoSwitching) - activateSwitchOnHls(text?.toString()) + downloadStreamButton.apply { + isGone = isLayout(TV) + setOnClickListener { showStreamInputDialog(it.context) } } + } - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( - 0 - )?.text?.toString()?.let { copy -> - val fixedText = copy.trim() - dialog.stream_url?.setText(fixedText) - activateSwitchOnHls(fixedText) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + handleScroll(scrollY - oldScrollY) } + } - dialog.apply_btt?.setOnClickListener { - val url = dialog.stream_url.text?.toString() - if (url.isNullOrEmpty()) { - showToast(activity, R.string.error_invalid_url, Toast.LENGTH_SHORT) - } else { - val referer = dialog.stream_referer.text?.toString() + context?.let { downloadsViewModel.updateHeaderList(it) } + fixPaddingStatusbar(binding?.downloadRoot) + } + private fun handleItemClick(click: DownloadHeaderClickEvent) { + when (click.action) { + DOWNLOAD_ACTION_GO_TO_CHILD -> { + if (click.data.type.isEpisodeBased()) { + val folder = + getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) activity?.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - LinkGenerator( - listOf(url), - extract = true, - referer = referer, - isM3u8 = dialog.hls_switch?.isChecked - ) - ) + R.id.action_navigation_downloads_to_navigation_download_child, + DownloadChildFragment.newInstance(click.data.name, folder) ) - - dialog.dismissSafe(activity) } } - dialog.cancel_btt?.setOnClickListener { + DOWNLOAD_ACTION_LOAD_RESULT -> { + activity?.loadResult(click.data.url, click.data.apiName) + } + } + } + + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadStorageAppbar?.isVisible = false + activity?.attachBackPressedCallback { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) + } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) + } + + private fun updateStorageInfo( + context: Context, + bytes: Long, + @StringRes stringRes: Int, + textView: TextView?, + view: View? + ) { + textView?.text = getString(R.string.storage_size_format).format( + getString(stringRes), + formatShortFileSize(context, bytes) + ) + view?.setLayoutWidth(bytes) + } + + private fun openLocalVideo() { + val intent = Intent() + .setAction(Intent.ACTION_GET_CONTENT) + .setType("video/*") + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access + normalSafeApiCall { + videoResultLauncher.launch( + Intent.createChooser( + intent, + getString(R.string.open_local_video) + ) + ) + } + } + + private fun showStreamInputDialog(context: Context) { + val dialog = Dialog(context, R.style.AlertDialogCustom) + val binding = StreamInputBinding.inflate(dialog.layoutInflater) + dialog.setContentView(binding.root) + dialog.show() + + var preventAutoSwitching = false + binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true } + + binding.streamReferer.doOnTextChanged { text, _, _, _ -> + if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding) + } + + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( + 0 + )?.text?.toString()?.let { copy -> + val fixedText = copy.trim() + binding.streamUrl.setText(fixedText) + activateSwitchOnHls(fixedText, binding) + } + + binding.applyBtt.setOnClickListener { + val url = binding.streamUrl.text?.toString() + if (url.isNullOrEmpty()) { + showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) + } else { + val referer = binding.streamReferer.text?.toString() + activity?.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + LinkGenerator( + listOf(BasicLink(url)), + extract = true, + referer = referer, + isM3u8 = binding.hlsSwitch.isChecked + ) + ) + ) dialog.dismissSafe(activity) } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - download_list?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val dy = scrollY - oldScrollY - if (dy > 0) { //check for scroll down - download_stream_button?.shrink() // hide - } else if (dy < -5) { - download_stream_button?.extend() // show - } - } - } - downloadsViewModel.updateList(requireContext()) - context?.fixPaddingStatusbar(download_root) + binding.cancelBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + } + + private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) { + binding.hlsSwitch.isChecked = normalSafeApiCall { + URI(text).path?.substringAfterLast(".")?.contains("m3u") + } == true + } + + private fun handleScroll(dy: Int) { + if (dy > 0) { + binding?.downloadStreamButton?.shrink() + } else if (dy < -5) { + binding?.downloadStreamButton?.extend() + } + } + + // Open local video from files using content provider x safeFile + private val videoResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult + playUri(activity ?: return@registerForActivityResult, selectedVideoUri) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt deleted file mode 100644 index 29bb303a..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.annotation.SuppressLint -import android.text.format.Formatter.formatShortFileSize -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.cardview.widget.CardView -import androidx.core.widget.ContentLoadingProgressBar -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import kotlinx.android.synthetic.main.download_header_episode.view.* -import java.util.* - -data class VisualDownloadHeaderCached( - val currentOngoingDownloads: Int, - val totalDownloads: Int, - val totalBytes: Long, - val currentBytes: Long, - val data: VideoDownloadHelper.DownloadHeaderCached, - val child: VideoDownloadHelper.DownloadEpisodeCached?, -) - -data class DownloadHeaderClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadHeaderCached) - -class DownloadHeaderAdapter( - var cardList: List, - private val clickCallback: (DownloadHeaderClickEvent) -> Unit, - private val movieClickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { - - private val mBoundViewHolders: HashSet = HashSet() - private fun getAllBoundViewHolders(): Set? { - return Collections.unmodifiableSet(mBoundViewHolders) - } - - fun killAdapter() { - getAllBoundViewHolders()?.forEach { view -> - view?.downloadButton?.dispose() - } - } - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - } - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - mBoundViewHolders.remove(holder) - } - } - - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.reattachDownloadButton() - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return DownloadHeaderViewHolder( - LayoutInflater.from(parent.context).inflate(R.layout.download_header_episode, parent, false), - clickCallback, - movieClickCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is DownloadHeaderViewHolder -> { - holder.bind(cardList[position]) - mBoundViewHolders.add(holder) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - class DownloadHeaderViewHolder - constructor( - itemView: View, - private val clickCallback: (DownloadHeaderClickEvent) -> Unit, - private val movieClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { - override var downloadButton = EasyDownloadButton() - - private val poster: ImageView? = itemView.download_header_poster - private val title: TextView = itemView.download_header_title - private val extraInfo: TextView = itemView.download_header_info - private val holder: CardView = itemView.episode_holder - - private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded - private val downloadImage: ImageView = itemView.download_header_episode_download - private val normalImage: ImageView = itemView.download_header_goto_child - private var localCard: VisualDownloadHeaderCached? = null - - @SuppressLint("SetTextI18n") - fun bind(card: VisualDownloadHeaderCached) { - localCard = card - val d = card.data - - poster?.setImage(d.poster) - poster?.setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(1, d)) - } - - title.text = d.name - val mbString = formatShortFileSize(itemView.context, card.totalBytes) - - //val isMovie = d.type.isMovieType() - if (card.child != null) { - downloadBar.visibility = View.VISIBLE - downloadImage.visibility = View.VISIBLE - normalImage.visibility = View.GONE - /*setUpButton( - card.currentBytes, - card.totalBytes, - downloadBar, - downloadImage, - extraInfo, - card.child, - movieClickCallback - )*/ - - holder.setOnClickListener { - movieClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child)) - } - } else { - downloadBar.visibility = View.GONE - downloadImage.visibility = View.GONE - normalImage.visibility = View.VISIBLE - - try { - extraInfo.text = - extraInfo.context.getString(R.string.extra_info_format).format( - card.totalDownloads, - if (card.totalDownloads == 1) extraInfo.context.getString(R.string.episode) else extraInfo.context.getString( - R.string.episodes - ), - mbString - ) - } catch (t : Throwable) { - // you probably formatted incorrectly - extraInfo.text = "Error" - logError(t) - } - - - holder.setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(0, d)) - } - } - } - - override fun reattachDownloadButton() { - downloadButton.dispose() - val card = localCard - if (card?.child != null) { - downloadButton.setUpButton( - card.currentBytes, - card.totalBytes, - downloadBar, - downloadImage, - extraInfo, - card.child, - movieClickCallback - ) - } - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 3a74a715..137f1355 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -1,122 +1,439 @@ package com.lagradost.cloudstream3.ui.download import android.content.Context +import android.content.DialogInterface import android.os.Environment import android.os.StatFs +import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.isMovieType +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { - private val _noDownloadsText = MutableLiveData().apply { - value = "" - } - val noDownloadsText: LiveData = _noDownloadsText - private val _headerCards = - MutableLiveData>().apply { listOf() } - val headerCards: LiveData> = _headerCards + private val _headerCards = MutableLiveData>() + val headerCards: LiveData> = _headerCards + + private val _childCards = MutableLiveData>() + val childCards: LiveData> = _childCards private val _usedBytes = MutableLiveData() - private val _availableBytes = MutableLiveData() - private val _downloadBytes = MutableLiveData() - val usedBytes: LiveData = _usedBytes + + private val _availableBytes = MutableLiveData() val availableBytes: LiveData = _availableBytes + + private val _downloadBytes = MutableLiveData() val downloadBytes: LiveData = _downloadBytes - fun updateList(context: Context) = viewModelScope.launchSafe { - val children = withContext(Dispatchers.IO) { - val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE) - headers.mapNotNull { context.getKey(it) } - .distinctBy { it.id } // Remove duplicates - } + private val _selectedBytes = MutableLiveData(0) + val selectedBytes: LiveData = _selectedBytes - // parentId : bytes - val totalBytesUsedByChild = HashMap() - // parentId : bytes - val currentBytesUsedByChild = HashMap() - // parentId : downloadsCount - val totalDownloads = HashMap() + private val _isMultiDeleteState = MutableLiveData(false) + val isMultiDeleteState: LiveData = _isMultiDeleteState + private val _selectedItemIds = MutableLiveData>(mutableSetOf()) + val selectedItemIds: LiveData> = _selectedItemIds - // Gets all children downloads - withContext(Dispatchers.IO) { - for (c in children) { - val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue + private var previousVisual: List? = null - if (childFile.fileLength <= 1) continue - val len = childFile.totalBytes - val flen = childFile.fileLength + fun setIsMultiDeleteState(value: Boolean) { + _isMultiDeleteState.postValue(value) + } - totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len - currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen - totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1 + fun addSelected(itemId: Int) { + updateSelectedItems { it.add(itemId) } + } + + fun removeSelected(itemId: Int) { + updateSelectedItems { it.remove(itemId) } + } + + fun selectAllItems() { + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + updateSelectedItems { it.addAll(items.map { item -> item.data.id }) } + } + + fun clearSelectedItems() { + // We need this to be done immediately + // so we can't use postValue + _selectedItemIds.value = mutableSetOf() + updateSelectedItems { it.clear() } + } + + fun isAllSelected(): Boolean { + val currentSelected = selectedItemIds.value ?: return false + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected } + } + + private fun updateSelectedItems(action: (MutableSet) -> Unit) { + val currentSelected = selectedItemIds.value ?: mutableSetOf() + action(currentSelected) + _selectedItemIds.postValue(currentSelected) + updateSelectedBytes() + updateSelectedCards() + } + + private fun updateSelectedBytes() = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData() ?: return@launchSafe + val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes } + _selectedBytes.postValue(totalSelectedBytes) + } + + private fun updateSelectedCards() = viewModelScope.launchSafe { + val currentSelected = selectedItemIds.value ?: return@launchSafe + + headerCards.value?.let { headers -> + headers.forEach { header -> + header.isSelected = header.data.id in currentSelected } + _headerCards.postValue(headers) } - val cached = withContext(Dispatchers.IO) { // wont fetch useless keys - totalDownloads.entries.filter { it.value > 0 }.mapNotNull { - context.getKey( - DOWNLOAD_HEADER_CACHE, - it.key.toString() - ) + childCards.value?.let { children -> + children.forEach { child -> + child.isSelected = child.data.id in currentSelected } + _childCards.postValue(children) } + } + fun updateHeaderList(context: Context) = viewModelScope.launchSafe { val visual = withContext(Dispatchers.IO) { - cached.mapNotNull { // TODO FIX - val downloads = totalDownloads[it.id] ?: 0 - val bytes = totalBytesUsedByChild[it.id] ?: 0 - val currentBytes = currentBytesUsedByChild[it.id] ?: 0 - if (bytes <= 0 || downloads <= 0) return@mapNotNull null - val movieEpisode = - if (!it.type.isMovieType()) null - else context.getKey( - DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) - ) - VisualDownloadHeaderCached( - 0, - downloads, - bytes, - currentBytes, - it, - movieEpisode - ) - }.sortedBy { - (it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0) - } // episode sorting by episode, lowest to highest + val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) + .mapNotNull { context.getKey(it) } + .distinctBy { it.id } // Remove duplicates + + val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = + calculateDownloadStats(context, children) + + val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) + .mapNotNull { context.getKey(it) } + + createVisualDownloadList( + context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads + ) } + + if (visual != previousVisual) { + previousVisual = visual + updateStorageStats(visual) + _headerCards.postValue(visual) + } + } + + private fun calculateDownloadStats( + context: Context, + children: List + ): Triple, Map, Map> { + // parentId : bytes + val totalBytesUsedByChild = mutableMapOf() + // parentId : bytes + val currentBytesUsedByChild = mutableMapOf() + // parentId : downloadsCount + val totalDownloads = mutableMapOf() + + children.forEach { child -> + val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach + if (childFile.fileLength <= 1) return@forEach + + val len = childFile.totalBytes + val flen = childFile.fileLength + + totalBytesUsedByChild.merge(child.parentId, len, Long::plus) + currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) + totalDownloads.merge(child.parentId, 1, Int::plus) + } + return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) + } + + private fun createVisualDownloadList( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + currentBytesUsedByChild: Map, + totalDownloads: Map + ): List { + return cached.mapNotNull { + val downloads = totalDownloads[it.id] ?: 0 + val bytes = totalBytesUsedByChild[it.id] ?: 0 + val currentBytes = currentBytesUsedByChild[it.id] ?: 0 + if (bytes <= 0 || downloads <= 0) return@mapNotNull null + + val isSelected = selectedItemIds.value?.contains(it.id) ?: false + val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(it.id.toString(), it.id.toString()) + ) + + VisualDownloadCached.Header( + currentBytes = currentBytes, + totalBytes = bytes, + data = it, + child = movieEpisode, + currentOngoingDownloads = 0, + totalDownloads = downloads, + isSelected = isSelected, + ) + // Prevent order being almost completely random, + // making things difficult to find. + }.sortedWith(compareBy { + // Sort by isEpisodeBased() ascending. We put those that + // are episode based at the bottom for UI purposes and to + // make it easier to find by grouping them together. + it.data.type.isEpisodeBased() + }.thenBy { + // Then we sort alphabetically by name (case-insensitive). + // Again, we do this to make things easier to find. + it.data.name.lowercase() + }) + } + + fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { + val visual = withContext(Dispatchers.IO) { + context.getKeys(folder).mapNotNull { key -> + context.getKey(key) + }.mapNotNull { + val isSelected = selectedItemIds.value?.contains(it.id) ?: false + val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null + VisualDownloadCached.Child( + currentBytes = info.fileLength, + totalBytes = info.totalBytes, + isSelected = isSelected, + data = it, + ) + } + }.sortedWith(compareBy( + // Sort by season first, and then by episode number, + // to ensure sorting is consistent. + { it.data.season ?: 0 }, + { it.data.episode } + )) + + if (previousVisual != visual) { + previousVisual = visual + _childCards.postValue(visual) + } + } + + private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { + val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove } + val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove } + _headerCards.postValue(updatedHeaders) + _childCards.postValue(updatedChildren) + } + + private fun updateStorageStats(visual: List) { try { val stat = StatFs(Environment.getExternalStorageDirectory().path) - - val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong + val localBytesAvailable = stat.availableBytes val localTotalBytes = stat.blockSizeLong * stat.blockCountLong val localDownloadedBytes = visual.sumOf { it.totalBytes } - - _usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes) + val localUsedBytes = localTotalBytes - localBytesAvailable + _usedBytes.postValue(localUsedBytes) _availableBytes.postValue(localBytesAvailable) _downloadBytes.postValue(localDownloadedBytes) - } catch (t : Throwable) { + } catch (t: Throwable) { _downloadBytes.postValue(0) logError(t) } - - _headerCards.postValue(visual) } -} + + fun handleMultiDelete(context: Context) = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData().orEmpty() + val deleteData = processSelectedItems(context, selectedItemsList) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + fun handleSingleDelete( + context: Context, + itemId: Int + ) = viewModelScope.launchSafe { + val itemData = getItemDataFromId(itemId) + val deleteData = processSelectedItems(context, itemData) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + private fun processSelectedItems( + context: Context, + selectedItemsList: List + ): DeleteData { + val names = mutableListOf() + val seriesNames = mutableListOf() + + val ids = mutableSetOf() + val parentIds = mutableSetOf() + + var parentName: String? = null + + selectedItemsList.forEach { item -> + when (item) { + is VisualDownloadCached.Header -> { + if (item.data.type.isEpisodeBased()) { + val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) + .mapNotNull { + context.getKey( + it + ) + } + .filter { it.parentId == item.data.id } + .map { it.id } + ids.addAll(episodes) + parentIds.add(item.data.id) + + val episodeInfo = "${item.data.name} (${item.totalDownloads} ${ + context.resources.getQuantityString( + R.plurals.episodes, + item.totalDownloads + ).lowercase() + })" + seriesNames.add(episodeInfo) + } else { + ids.add(item.data.id) + names.add(item.data.name) + } + } + + is VisualDownloadCached.Child -> { + ids.add(item.data.id) + val parent = context.getKey( + DOWNLOAD_HEADER_CACHE, + item.data.parentId.toString() + ) + parentName = parent?.name + names.add( + context.getNameFull( + item.data.name, + item.data.episode, + item.data.season + ) + ) + } + } + } + + return DeleteData(ids, parentIds, seriesNames, names, parentName) + } + + private fun buildDeleteMessage( + context: Context, + data: DeleteData + ): String { + val formattedNames = data.names.sortedBy { it.lowercase() } + .joinToString(separator = "\n") { "• $it" } + val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() } + .joinToString(separator = "\n") { "• $it" } + + return when { + data.ids.count() == 1 -> { + context.getString(R.string.delete_message).format( + data.names.firstOrNull() + ) + } + + data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { + context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) + } + + data.parentName != null && data.names.isNotEmpty() -> { + context.getString(R.string.delete_message_series_episodes) + .format(data.parentName, formattedNames) + } + + data.seriesNames.isNotEmpty() -> { + val seriesSection = context.getString(R.string.delete_message_series_section) + .format(formattedSeriesNames) + context.getString(R.string.delete_message_multiple) + .format(formattedNames) + "\n\n" + seriesSection + } + + else -> context.getString(R.string.delete_message_multiple).format(formattedNames) + } + } + + private fun showDeleteConfirmationDialog( + context: Context, + message: String, + ids: Set, + parentIds: Set + ) { + val builder = AlertDialog.Builder(context) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + viewModelScope.launchSafe { + setIsMultiDeleteState(false) + deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> + // We always remove parent because if we are deleting from here + // and we have it as non-empty, it was triggered on + // parent header card + removeItems(successfulIds + parentIds) + } + } + } + + DialogInterface.BUTTON_NEGATIVE -> { + // Do nothing on cancel + } + } + } + + try { + val title = if (ids.count() == 1) { + R.string.delete_file + } else R.string.delete_files + builder.setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + } + } + + private fun getSelectedItemsData(): List? { + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() + + return selectedItemIds.value?.mapNotNull { id -> + headers.find { it.data.id == id } ?: children.find { it.data.id == id } + } + } + + private fun getItemDataFromId(itemId: Int): List { + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() + + return (headers + children).filter { it.data.id == itemId } + } + + private data class DeleteData( + val ids: Set, + val parentIds: Set, + val seriesNames: List, + val names: List, + val parentName: String? + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt deleted file mode 100644 index 77878432..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.animation.ObjectAnimator -import android.text.format.Formatter.formatShortFileSize -import android.view.View -import android.view.animation.DecelerateInterpolator -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.widget.ContentLoadingProgressBar -import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.Coroutines -import com.lagradost.cloudstream3.utils.IDisposable -import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.VideoDownloadManager - -class EasyDownloadButton : IDisposable { - interface IMinimumData { - val id: Int - } - - private var _clickCallback: ((DownloadClickEvent) -> Unit)? = null - private var _imageChangeCallback: ((Pair) -> Unit)? = null - - override fun dispose() { - try { - _clickCallback = null - _imageChangeCallback = null - downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent -= it } - downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent -= it } - } catch (e: Exception) { - e.printStackTrace() - } - } - - private var downloadProgressEventListener: ((Triple) -> Unit)? = null - private var downloadStatusEventListener: ((Pair) -> Unit)? = - null - - fun setUpMaterialButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadButton: MaterialButton, - textView: TextView?, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textView, - data, - downloadButton, - { - downloadButton.setIconResource(it.first) - downloadButton.text = it.second - }, - clickCallback - ) - } - - fun setUpMoreButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadImage: ImageView, - textView: TextView?, - textViewProgress: TextView?, - clickableView: View, - isTextPercentage: Boolean, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textViewProgress, - data, - clickableView, - { (image, text) -> - downloadImage.isVisible = textViewProgress?.isGone ?: true - downloadImage.setImageResource(image) - textView?.text = text - }, - clickCallback, isTextPercentage - ) - } - - fun setUpButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadImage: ImageView, - textView: TextView?, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textView, - data, - downloadImage, - { - downloadImage.setImageResource(it.first) - }, - clickCallback - ) - } - - private fun setUpDownloadButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - textView: TextView?, - data: IMinimumData, - downloadView: View, - downloadImageChangeCallback: (Pair) -> Unit, - clickCallback: (DownloadClickEvent) -> Unit, - isTextPercentage: Boolean = false - ) { - _clickCallback = clickCallback - _imageChangeCallback = downloadImageChangeCallback - var lastState: VideoDownloadManager.DownloadType? = null - var currentBytes = setupCurrentBytes ?: 0 - var totalBytes = setupTotalBytes ?: 0 - var needImageUpdate = true - - fun changeDownloadImage(state: VideoDownloadManager.DownloadType) { - lastState = state - if (currentBytes <= 0) needImageUpdate = true - val img = if (currentBytes > 0) { - when (state) { - VideoDownloadManager.DownloadType.IsPaused -> Pair( - R.drawable.ic_baseline_play_arrow_24, - R.string.download_paused - ) - VideoDownloadManager.DownloadType.IsDownloading -> Pair( - R.drawable.netflix_pause, - R.string.downloading - ) - else -> Pair(R.drawable.ic_baseline_delete_outline_24, R.string.downloaded) - } - } else { - Pair(R.drawable.netflix_download, R.string.download) - } - _imageChangeCallback?.invoke( - Pair( - img.first, - downloadView.context.getString(img.second) - ) - ) - } - - fun fixDownloadedBytes(setCurrentBytes: Long, setTotalBytes: Long, animate: Boolean) { - currentBytes = setCurrentBytes - totalBytes = setTotalBytes - - if (currentBytes == 0L) { - changeDownloadImage(VideoDownloadManager.DownloadType.IsStopped) - textView?.visibility = View.GONE - progressBar.visibility = View.GONE - } else { - if (lastState == VideoDownloadManager.DownloadType.IsStopped) { - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - } - textView?.visibility = View.VISIBLE - progressBar.visibility = View.VISIBLE - val currentMbString = formatShortFileSize(textView?.context, setCurrentBytes) - val totalMbString = formatShortFileSize(textView?.context, setTotalBytes) - - textView?.text = - if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else - textView?.context?.getString(R.string.download_size_format) - ?.format(currentMbString, totalMbString) - - progressBar.let { bar -> - bar.max = (setTotalBytes / 1000).toInt() - - if (animate) { - val animation: ObjectAnimator = ObjectAnimator.ofInt( - bar, - "progress", - bar.progress, - (setCurrentBytes / 1000).toInt() - ) - animation.duration = 500 - animation.setAutoCancel(true) - animation.interpolator = DecelerateInterpolator() - animation.start() - } else { - bar.progress = (setCurrentBytes / 1000).toInt() - } - } - } - } - - fixDownloadedBytes(currentBytes, totalBytes, false) - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - - downloadProgressEventListener = { downloadData: Triple -> - if (data.id == downloadData.first) { - if (downloadData.second != currentBytes || downloadData.third != totalBytes) { // TO PREVENT WASTING UI TIME - Coroutines.runOnMainThread { - fixDownloadedBytes(downloadData.second, downloadData.third, true) - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - } - } - } - } - - downloadStatusEventListener = - { downloadData: Pair -> - if (data.id == downloadData.first) { - if (lastState != downloadData.second || needImageUpdate) { // TO PREVENT WASTING UI TIME - Coroutines.runOnMainThread { - changeDownloadImage(downloadData.second) - } - } - } - } - - downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent += it } - downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent += it } - - downloadView.setOnClickListener { - if (currentBytes <= 0 || totalBytes <= 0) { - _clickCallback?.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) - } else { - val list = arrayListOf( - Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), - Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file), - ) - - // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && - if ((currentBytes * 100 / totalBytes) < 98) { - list.add( - if (lastState == VideoDownloadManager.DownloadType.IsDownloading) - Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) - else - Pair(DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download) - ) - } - - it.popupMenuNoIcons( - list - ) { - _clickCallback?.invoke(DownloadClickEvent(itemId, data)) - } - } - } - - downloadView.setOnLongClickListener { - clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) - return@setOnLongClickListener true - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt new file mode 100644 index 00000000..908e3a80 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -0,0 +1,218 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.content.Context +import android.text.format.Formatter.formatShortFileSize +import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.LayoutRes +import androidx.core.view.isVisible +import androidx.core.widget.ContentLoadingProgressBar +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.mainWork +import com.lagradost.cloudstream3.utils.VideoDownloadManager + +typealias DownloadStatusTell = VideoDownloadManager.DownloadType + +data class DownloadMetadata( + var id: Int, + var downloadedLength: Long, + var totalLength: Long, + var status: DownloadStatusTell? = null +) { + val progressPercentage: Long + get() = if (downloadedLength < 1024) 0 else maxOf( + 0, + minOf(100, (downloadedLength * 100L) / (totalLength + 1)) + ) +} + +abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : + FrameLayout(context, attributeSet) { + + var persistentId: Int? = null // used to save sessions + + lateinit var progressBar: ContentLoadingProgressBar + var progressText: TextView? = null + + /* val gid: String? get() = sessionIdToGid[persistentId] + + // used for resuming data + var _lastRequestOverride: UriRequest? = null + var lastRequest: UriRequest? + get() = _lastRequestOverride ?: sessionIdToLastRequest[persistentId] + set(value) { + _lastRequestOverride = value + } + + var files: List = emptyList() */ + protected var isZeroBytes: Boolean = true + + fun inflate(@LayoutRes layout: Int) { + inflate(context, layout, this) + } + + init { + @Suppress("LeakingThis") + resetViewData() + } + + var doSetProgress = true + + open fun resetViewData() { + // lastRequest = null + isZeroBytes = true + doSetProgress = true + persistentId = null + } + + var currentMetaData: DownloadMetadata = + DownloadMetadata(0, 0, 0, null) + + fun setPersistentId(id: Int) { + persistentId = id + currentMetaData.id = id + + if (!doSetProgress) return + + ioSafe { + val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) + + mainWork { + if (savedData != null) { + val downloadedBytes = savedData.fileLength + val totalBytes = savedData.totalBytes + + setProgress(downloadedBytes, totalBytes) + applyMetaData(id, downloadedBytes, totalBytes) + } else run { resetView() } + } + } + } + + abstract fun setStatus(status: VideoDownloadManager.DownloadType?) + + fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell { + // some extra padding for just in case + return VideoDownloadManager.downloadStatus[id] + ?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) { + DownloadStatusTell.IsDone + } else DownloadStatusTell.IsPaused + } + + fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) { + val status = getStatus(id, downloadedBytes, totalBytes) + + currentMetaData.apply { + this.id = id + this.downloadedLength = downloadedBytes + this.totalLength = totalBytes + this.status = status + } + setStatus(status) + } + + open fun setProgress(downloadedBytes: Long, totalBytes: Long) { + isZeroBytes = downloadedBytes == 0L + progressBar.post { + val steps = 10000L + progressBar.max = steps.toInt() + // div by zero error and 1 byte off is ok impo + + val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt() + + val animation = ProgressBarAnimation( + progressBar, + progressBar.progress.toFloat(), + progress.toFloat() + ).apply { + fillAfter = true + duration = + if (progress > progressBar.progress) // we don't want to animate backward changes in progress + 100 + else + 0L + } + + if (isZeroBytes) { + progressText?.isVisible = false + } else { + if (doSetProgress) { + progressText?.apply { + val currentFormattedSizeString = + formatShortFileSize(context, downloadedBytes) + val totalFormattedSizeString = formatShortFileSize(context, totalBytes) + text = + // if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else + context?.getString(R.string.download_size_format) + ?.format(currentFormattedSizeString, totalFormattedSizeString) + } + } + } + + progressBar.startAnimation(animation) + } + } + + fun downloadStatusEvent(data: Pair) { + val (id, status) = data + if (id == persistentId) { + currentMetaData.status = status + setStatus(status) + } + } + + /*fun downloadDeleteEvent(data: Int) { + + }*/ + + /*fun downloadEvent(data: Pair) { + val (id, action) = data + + }*/ + + fun downloadProgressEvent(data: Triple) { + val (id, bytesDownloaded, bytesTotal) = data + if (id == persistentId) { + currentMetaData.downloadedLength = bytesDownloaded + currentMetaData.totalLength = bytesTotal + + setProgress(bytesDownloaded, bytesTotal) + } + } + + override fun onAttachedToWindow() { + VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent + // VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent + // VideoDownloadManager.downloadEvent += ::downloadEvent + VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent + + val pid = persistentId + if (pid != null) { + // refresh in case of onDetachedFromWindow -> onAttachedToWindow while still being ??????? + setPersistentId(pid) + } + + super.onAttachedToWindow() + } + + override fun onDetachedFromWindow() { + VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent + // VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent + // VideoDownloadManager.downloadEvent -= ::downloadEvent + VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent + + super.onDetachedFromWindow() + } + + /** + * No checks required. Arg will always include a download with current id + * */ + abstract fun updateViewOnDownload(metadata: DownloadMetadata) + + /** + * Get a clean slate again, might be useful in recyclerview? + * */ + abstract fun resetView() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt new file mode 100644 index 00000000..20a44461 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -0,0 +1,59 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.utils.VideoDownloadHelper + +class DownloadButton(context: Context, attributeSet: AttributeSet) : + PieFetchButton(context, attributeSet) { + + private var mainText: TextView? = null + override fun onAttachedToWindow() { + super.onAttachedToWindow() + progressText = findViewById(R.id.result_movie_download_text_precentage) + mainText = findViewById(R.id.result_movie_download_text) + } + + override fun setStatus(status: DownloadStatusTell?) { + mainText?.post { + val txt = when (status) { + DownloadStatusTell.IsPaused -> R.string.download_paused + DownloadStatusTell.IsDownloading -> R.string.downloading + DownloadStatusTell.IsDone -> R.string.downloaded + else -> R.string.download + } + mainText?.setText(txt) + } + super.setStatus(status) + + } + + override fun setDefaultClickListener( + card: VideoDownloadHelper.DownloadEpisodeCached, + textView: TextView?, + callback: (DownloadClickEvent) -> Unit + ) { + this.setDefaultClickListener( + this.findViewById(R.id.download_movie_button), + textView, + card, + callback + ) + } + + @SuppressLint("SetTextI18n") + override fun updateViewOnDownload(metadata: DownloadMetadata) { + super.updateViewOnDownload(metadata) + + val isVis = metadata.progressPercentage > 0 + progressText?.isVisible = isVis + if (isVis) + progressText?.text = "${metadata.progressPercentage}%" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt new file mode 100644 index 00000000..29c2daa2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -0,0 +1,339 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.content.Context +import android.os.Looper +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.animation.AnimationUtils +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.MainThread +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES + +open class PieFetchButton(context: Context, attributeSet: AttributeSet) : + BaseFetchButton(context, attributeSet) { + + private var waitingAnimation: Int = 0 + private var animateWaiting: Boolean = false + private var activeOutline: Int = 0 + private var nonActiveOutline: Int = 0 + + private var iconInit: Int = 0 + private var iconError: Int = 0 + private var iconComplete: Int = 0 + private var iconActive: Int = 0 + private var iconWaiting: Int = 0 + private var iconRemoved: Int = 0 + private var iconPaused: Int = 0 + private var hideWhenIcon: Boolean = true + + var progressDrawable: Int = 0 + + var overrideLayout: Int? = null + + companion object { + val fillArray = arrayOf( + R.drawable.circular_progress_bar_clockwise, + R.drawable.circular_progress_bar_counter_clockwise, + R.drawable.circular_progress_bar_small_to_large, + R.drawable.circular_progress_bar_top_to_bottom, + ) + } + + private var progressBarBackground: View + var statusView: ImageView + + open fun onInflate() {} + + init { + context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { + try { + inflate( + overrideLayout ?: getResourceId( + R.styleable.PieFetchButton_download_layout, + R.layout.download_button_view + ) + ) + } catch (e: Exception) { + Log.e( + "PieFetchButton", "Error inflating PieFetchButton, " + + "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" + ) + throw e + } + + + progressBar = findViewById(R.id.progress_downloaded) + progressBarBackground = findViewById(R.id.progress_downloaded_background) + statusView = findViewById(R.id.image_download_status) + + animateWaiting = getBoolean( + R.styleable.PieFetchButton_download_animate_waiting, + true + ) + hideWhenIcon = getBoolean( + R.styleable.PieFetchButton_download_hide_when_icon, + true + ) + + waitingAnimation = getResourceId( + R.styleable.PieFetchButton_download_waiting_animation, + R.anim.rotate_around_center_point + ) + + activeOutline = getResourceId( + R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape + ) + + nonActiveOutline = getResourceId( + R.styleable.PieFetchButton_download_outline_non_active, + R.drawable.circle_shape_dotted + ) + iconInit = getResourceId( + R.styleable.PieFetchButton_download_icon_init, R.drawable.netflix_download + ) + iconError = getResourceId( + R.styleable.PieFetchButton_download_icon_paused, R.drawable.download_icon_error + ) + iconComplete = getResourceId( + R.styleable.PieFetchButton_download_icon_complete, R.drawable.download_icon_done + ) + iconPaused = getResourceId( + R.styleable.PieFetchButton_download_icon_paused, 0 // R.drawable.download_icon_pause + ) + iconActive = getResourceId( + R.styleable.PieFetchButton_download_icon_active, 0 // R.drawable.download_icon_load + ) + iconWaiting = getResourceId( + R.styleable.PieFetchButton_download_icon_waiting, 0 + ) + iconRemoved = getResourceId( + R.styleable.PieFetchButton_download_icon_removed, R.drawable.netflix_download + ) + + val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) + + progressDrawable = getResourceId( + R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] + ) + + progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) + + recycle() + } + resetView() + onInflate() + } + + private var currentStatus: DownloadStatusTell? = null + /*private fun getActivity(): Activity? { + var context = context + while (context is ContextWrapper) { + if (context is Activity) { + return context + } + context = context.baseContext + } + return null + } + + fun callback(event : DownloadClickEvent) { + handleDownloadClick( + getActivity(), + event + ) + }*/ + + protected fun setDefaultClickListener( + view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, + callback: (DownloadClickEvent) -> Unit + ) { + this.progressText = textView + this.setPersistentId(card.id) + view.setOnClickListener { + if (isZeroBytes) { + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) + } else { + val list = arrayListOf( + Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), + Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file), + ) + + currentMetaData.apply { + // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && + if (progressPercentage < 98) { + list.add( + if (status == VideoDownloadManager.DownloadType.IsDownloading) + Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) + else + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + } + + + it.popupMenuNoIcons( + list + ) { + callback(DownloadClickEvent(itemId, card)) + // callback.invoke(DownloadClickEvent(itemId, data)) + } + } + } + + view.setOnLongClickListener { + callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card)) + + // clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) + return@setOnLongClickListener true + } + } + + open fun setDefaultClickListener( + card: VideoDownloadHelper.DownloadEpisodeCached, + textView: TextView?, + callback: (DownloadClickEvent) -> Unit + ) { + setDefaultClickListener(this, textView, card, callback) + } + + /* open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List) { + this.setOnClickListener { + when (this.currentStatus) { + null -> { + setStatus(DownloadStatusTell.IsPending) + ioThread { + val request = requestGetter.invoke(this) + if (request.size == 1) { + performDownload(request.first()) + } else if (request.isNotEmpty()) { + performFailQueueDownload(request) + } + } + } + DownloadStatusTell.Paused -> { + resumeDownload() + } + DownloadStatusTell.Active -> { + pauseDownload() + } + DownloadStatusTell.Error -> { + redownload() + } + else -> {} + } + } + } */ + + @MainThread + private fun setStatusInternal(status: DownloadStatusTell?) { + val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading + if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { + val animation = AnimationUtils.loadAnimation(context, waitingAnimation) + progressBarBackground.startAnimation(animation) + } else { + progressBarBackground.clearAnimation() + } + + val progressDrawable = + if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline + + progressBarBackground.background = + ContextCompat.getDrawable(context, progressDrawable) + + val drawable = + getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(this.context, it) } + statusView.setImageDrawable(drawable) + val isDrawable = drawable != null + + statusView.isVisible = isDrawable + val hide = hideWhenIcon && isDrawable + if (hide) { + progressBar.clearAnimation() + progressBarBackground.clearAnimation() + } + progressBarBackground.isGone = hide + progressBar.isGone = hide + } + + /** Also sets currentStatus */ + override fun setStatus(status: DownloadStatusTell?) { + currentStatus = status + + // Runs on the main thread, but also instant if it already is + if (Looper.myLooper() == Looper.getMainLooper()) { + try { + setStatusInternal(status) + } catch (t: Throwable) { + logError(t) // Just in case setStatusInternal throws because thread + progressBarBackground.post { + setStatusInternal(status) + } + } + } else { + progressBarBackground.post { + setStatusInternal(status) + } + } + } + + override fun resetView() { + setStatus(null) + currentMetaData = DownloadMetadata(0, 0, 0, null) + isZeroBytes = true + doSetProgress = true + progressBar.progress = 0 + } + + override fun updateViewOnDownload(metadata: DownloadMetadata) { + + val newStatus = metadata.status + + if (newStatus == null) { + resetView() + return + } + + val isDone = + newStatus == DownloadStatusTell.IsDone || (metadata.downloadedLength > 1024 && metadata.downloadedLength + 1024 >= metadata.totalLength) + + if (isDone) + setStatus(DownloadStatusTell.IsDone) + else { + setProgress(metadata.downloadedLength, metadata.totalLength) + setStatus(newStatus) + } + } + + open fun getDrawableFromStatus(status: DownloadStatusTell?): Int? = when (status) { + DownloadStatusTell.IsPaused -> iconPaused + DownloadStatusTell.IsPending -> iconWaiting + DownloadStatusTell.IsDownloading -> iconActive + DownloadStatusTell.IsFailed -> iconError + DownloadStatusTell.IsDone -> iconComplete + DownloadStatusTell.IsStopped -> iconRemoved + else -> iconInit + }.takeIf { it != 0 } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/ProgressBarAnimation.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/ProgressBarAnimation.kt new file mode 100644 index 00000000..11818a7e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/ProgressBarAnimation.kt @@ -0,0 +1,18 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.view.animation.Animation +import android.view.animation.Transformation +import android.widget.ProgressBar + +class ProgressBarAnimation( + private val progressBar: ProgressBar, + private val from: Float, + private val to: Float +) : + Animation() { + override fun applyTransformation(interpolatedTime: Float, t: Transformation?) { + super.applyTransformation(interpolatedTime, t) + val value = from + (to - from) * interpolatedTime + progressBar.progress = value.toInt() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index b90a4e43..b25486eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt @@ -1,146 +1,142 @@ package com.lagradost.cloudstream3.ui.home import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.databinding.HomeResultGridBinding +import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder -import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.home_result_grid.view.background_card -import kotlinx.android.synthetic.main.home_result_grid_expanded.view.* -class HomeChildItemAdapter( - val cardList: MutableList, - private val overrideLayout: Int? = null, - private val nextFocusUp: Int? = null, - private val nextFocusDown: Int? = null, - private val clickCallback: (SearchClickCallback) -> Unit, -) : - RecyclerView.Adapter() { - var isHorizontal: Boolean = false - var hasNext: Boolean = false - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = overrideLayout - ?: if (parent.context.IsBottomLayout()) R.layout.home_result_grid_expanded else R.layout.home_result_grid - - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - clickCallback, - itemCount, - nextFocusUp, - nextFocusDown, - isHorizontal - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.itemCount = itemCount // i know ugly af - holder.bind(cardList[position], position) - } +class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(view) { + /*private fun recursive(view : View) : Boolean { + if (view.isFocused) { + println("VIEW: $view | id=${view.id}") } - } + return (view as? ViewGroup)?.children?.any { recursive(it) } ?: false + }*/ - override fun getItemCount(): Int { - return cardList.size - } - - override fun getItemId(position: Int): Long { - return (cardList[position].id ?: position).toLong() - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - HomeChildDiffCallback(this.cardList, newList) - ) - - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - class CardViewHolder - constructor( - itemView: View, - private val clickCallback: (SearchClickCallback) -> Unit, - var itemCount: Int, - private val nextFocusUp: Int? = null, - private val nextFocusDown: Int? = null, - private val isHorizontal: Boolean = false - ) : - RecyclerView.ViewHolder(itemView) { - - fun bind(card: SearchResponse, position: Int) { - - // TV focus fixing - val nextFocusBehavior = when (position) { - 0 -> true - itemCount - 1 -> false - else -> null + // very shitty that we cant store the state when the view clears, + // but this is because the focus clears before the view is removed + // so we have to manually store it + var wasFocused: Boolean = false + override fun save(): Boolean = wasFocused + override fun restore(state: Boolean) { + if (state) { + wasFocused = false + // only refocus if tv + if(isLayout(TV)) { + itemView.requestFocus() } - - (itemView.image_holder ?: itemView.background_card)?.apply { - val min = 114.toPx - val max = 180.toPx - - layoutParams = - layoutParams.apply { - width = if (!isHorizontal) { - min - } else { - max - } - height = if (!isHorizontal) { - max - } else { - min - } - } - } - - - SearchResultBuilder.bind( - clickCallback, - card, - position, - itemView, - nextFocusBehavior, - nextFocusUp, - nextFocusDown - ) - itemView.tag = position - - if (position == 0) { // to fix tv - itemView.background_card?.nextFocusLeftId = R.id.nav_rail_view - } - //val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f) - //ani.fillAfter = true - //ani.duration = 200 - //itemView.startAnimation(ani) } } } -class HomeChildDiffCallback( - private val oldList: List, - private val newList: List +class HomeChildItemAdapter( + fragment: Fragment, + id: Int, + private val nextFocusUp: Int? = null, + private val nextFocusDown: Int? = null, + private val clickCallback: (SearchClickCallback) -> Unit, ) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].name == newList[newItemPosition].name + BaseAdapter(fragment, id) { + var isHorizontal: Boolean = false + var hasNext: Boolean = false - override fun getOldListSize() = oldList.size + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val expanded = parent.context.isBottomLayout() + /* val layout = if (bottom) R.layout.home_result_grid_expanded else R.layout.home_result_grid - override fun getNewListSize() = newList.size + val root = LayoutInflater.from(parent.context).inflate(layout, parent, false) + val binding = HomeResultGridBinding.bind(root)*/ - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] && oldItemPosition < oldList.size - 1 // always update the last item -} \ No newline at end of file + val inflater = LayoutInflater.from(parent.context) + val binding = if (expanded) HomeResultGridExpandedBinding.inflate( + inflater, + parent, + false + ) else HomeResultGridBinding.inflate(inflater, parent, false) + return HomeScrollViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: SearchResponse, + position: Int + ) { + when (val binding = holder.view) { + is HomeResultGridBinding -> { + binding.backgroundCard.apply { + val min = 114.toPx + val max = 180.toPx + + layoutParams = + layoutParams.apply { + width = if (!isHorizontal) { + min + } else { + max + } + height = if (!isHorizontal) { + max + } else { + min + } + } + } + } + + is HomeResultGridExpandedBinding -> { + binding.backgroundCard.apply { + val min = 114.toPx + val max = 180.toPx + + layoutParams = + layoutParams.apply { + width = if (!isHorizontal) { + min + } else { + max + } + height = if (!isHorizontal) { + max + } else { + min + } + } + } + + if (position == 0) { // to fix tv + binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view + } + } + } + + SearchResultBuilder.bind( + clickCallback = { click -> + // ok, so here we hijack the callback to fix the focus + when (click.action) { + SEARCH_ACTION_LOAD -> (holder as? HomeScrollViewHolderState)?.wasFocused = true + } + clickCallback(click) + }, + item, + position, + holder.itemView, + nextFocusUp, + nextFocusDown + ) + + holder.itemView.tag = position + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 5cf6fc8e..49de2503 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -7,7 +7,6 @@ import android.content.DialogInterface import android.content.Intent import android.content.res.Configuration import android.net.Uri -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -18,76 +17,49 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.* import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.button.MaterialButton import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.databinding.FragmentHomeBinding +import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding +import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding +import com.lagradost.cloudstream3.databinding.TvtypesChipsBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi -import com.lagradost.cloudstream3.ui.AutofitRecyclerView -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching -import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.ownHide -import com.lagradost.cloudstream3.utils.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide +import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API -import kotlinx.android.synthetic.main.activity_main_tv.* -import kotlinx.android.synthetic.main.fragment_home.* -import kotlinx.android.synthetic.main.fragment_home.home_api_fab -import kotlinx.android.synthetic.main.fragment_home.home_change_api_loading -import kotlinx.android.synthetic.main.fragment_home.home_loading -import kotlinx.android.synthetic.main.fragment_home.home_loading_error -import kotlinx.android.synthetic.main.fragment_home.home_loading_shimmer -import kotlinx.android.synthetic.main.fragment_home.home_loading_statusbar -import kotlinx.android.synthetic.main.fragment_home.home_master_recycler -import kotlinx.android.synthetic.main.fragment_home.home_reload_connection_open_in_browser -import kotlinx.android.synthetic.main.fragment_home.home_reload_connectionerror -import kotlinx.android.synthetic.main.fragment_home.result_error_text -import kotlinx.android.synthetic.main.fragment_home_tv.* -import kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_search.* -import kotlinx.android.synthetic.main.home_episodes_expanded.* -import kotlinx.android.synthetic.main.tvtypes_chips.* -import kotlinx.android.synthetic.main.tvtypes_chips.view.* import java.util.* -const val HOME_BOOKMARK_VALUE_LIST = "home_bookmarked_last_list" -const val HOME_PREF_HOMEPAGE = "home_pref_homepage" - class HomeFragment : Fragment() { companion object { val configEvent = Event() @@ -125,22 +97,26 @@ class HomeFragment : Fragment() { expand: HomeViewModel.ExpandableHomepageList, deleteCallback: (() -> Unit)? = null, expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null, - dismissCallback : (() -> Unit), + dismissCallback: (() -> Unit), ): BottomSheetDialog { val context = this val bottomSheetDialogBuilder = BottomSheetDialog(context) - - bottomSheetDialogBuilder.setContentView(R.layout.home_episodes_expanded) - val title = bottomSheetDialogBuilder.findViewById(R.id.home_expanded_text)!! + val binding: HomeEpisodesExpandedBinding = HomeEpisodesExpandedBinding.inflate( + bottomSheetDialogBuilder.layoutInflater, + null, + false + ) + bottomSheetDialogBuilder.setContentView(binding.root) + //val title = bottomSheetDialogBuilder.findViewById(R.id.home_expanded_text)!! //title.findViewTreeLifecycleOwner().lifecycle.addObserver() val item = expand.list - title.text = item.name - val recycle = - bottomSheetDialogBuilder.findViewById(R.id.home_expanded_recycler)!! - val titleHolder = - bottomSheetDialogBuilder.findViewById(R.id.home_expanded_drag_down)!! + binding.homeExpandedText.text = item.name + // val recycle = + // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_recycler)!! + //val titleHolder = + // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_drag_down)!! // main { //(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply { @@ -159,10 +135,10 @@ class HomeFragment : Fragment() { // }) //} // } - val delete = bottomSheetDialogBuilder.home_expanded_delete - delete.isGone = deleteCallback == null + //val delete = bottomSheetDialogBuilder.home_expanded_delete + binding.homeExpandedDelete.isGone = deleteCallback == null if (deleteCallback != null) { - delete.setOnClickListener { + binding.homeExpandedDelete.setOnClickListener { try { val builder: AlertDialog.Builder = AlertDialog.Builder(context) val dialogClickListener = @@ -172,6 +148,7 @@ class HomeFragment : Fragment() { deleteCallback.invoke() bottomSheetDialogBuilder.dismissSafe(this) } + DialogInterface.BUTTON_NEGATIVE -> {} } } @@ -191,26 +168,27 @@ class HomeFragment : Fragment() { } } } - - titleHolder.setOnClickListener { + binding.homeExpandedDragDown.setOnClickListener { bottomSheetDialogBuilder.dismissSafe(this) } // Span settings - recycle.spanCount = currentSpan + binding.homeExpandedRecycler.spanCount = currentSpan - recycle.adapter = SearchAdapter(item.list.toMutableList(), recycle) { callback -> - handleSearchClickCallback(this, callback) - if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { - bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later - //bottomSheetDialogBuilder.dismissSafe(this) + binding.homeExpandedRecycler.adapter = + SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback -> + handleSearchClickCallback(callback) + if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { + bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later + //bottomSheetDialogBuilder.dismissSafe(this) + } + }.apply { + hasNext = expand.hasNext } - }.apply { - hasNext = expand.hasNext - } - recycle.addOnScrollListener(object : RecyclerView.OnScrollListener() { + binding.homeExpandedRecycler.addOnScrollListener(object : + RecyclerView.OnScrollListener() { var expandCount = 0 val name = expand.list.name @@ -238,7 +216,7 @@ class HomeFragment : Fragment() { }) val spanListener = { span: Int -> - recycle.spanCount = span + binding.homeExpandedRecycler.spanCount = span //(recycle.adapter as SearchAdapter).notifyDataSetChanged() } @@ -255,7 +233,7 @@ class HomeFragment : Fragment() { return bottomSheetDialogBuilder } - fun getPairList( + private fun getPairList( anime: Chip?, cartoons: Chip?, tvs: Chip?, @@ -280,19 +258,19 @@ class HomeFragment : Fragment() { ) } - private fun getPairList(header: ChipGroup) = getPairList( - header.home_select_anime, - header.home_select_cartoons, - header.home_select_tv_series, - header.home_select_documentaries, - header.home_select_movies, - header.home_select_asian, - header.home_select_livestreams, - header.home_select_nsfw, - header.home_select_others + private fun getPairList(header: TvtypesChipsBinding) = getPairList( + header.homeSelectAnime, + header.homeSelectCartoons, + header.homeSelectTvSeries, + header.homeSelectDocumentaries, + header.homeSelectMovies, + header.homeSelectAsian, + header.homeSelectLivestreams, + header.homeSelectNsfw, + header.homeSelectOthers ) - fun validateChips(header: ChipGroup?, validTypes: List) { + fun validateChips(header: TvtypesChipsBinding?, validTypes: List) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { @@ -301,7 +279,7 @@ class HomeFragment : Fragment() { } } - fun updateChips(header: ChipGroup?, selectedTypes: List) { + fun updateChips(header: TvtypesChipsBinding?, selectedTypes: List) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { @@ -311,10 +289,21 @@ class HomeFragment : Fragment() { } fun bindChips( - header: ChipGroup?, + header: TvtypesChipsBinding?, selectedTypes: List, validTypes: List, callback: (List) -> Unit + ) { + bindChips(header, selectedTypes, validTypes, callback, null, null) + } + + fun bindChips( + header: TvtypesChipsBinding?, + selectedTypes: List, + validTypes: List, + callback: (List) -> Unit, + nextFocusDown: Int?, + nextFocusUp: Int? ) { if (header == null) return val pairList = getPairList(header) @@ -322,6 +311,17 @@ class HomeFragment : Fragment() { val isValid = validTypes.any { types.contains(it) } button?.isVisible = isValid button?.isChecked = isValid && selectedTypes.any { types.contains(it) } + button?.isFocusable = true + if (isLayout(TV)) { + button?.isFocusableInTouchMode = true + } + + if (nextFocusDown != null) + button?.nextFocusDownId = nextFocusDown + + if (nextFocusUp != null) + button?.nextFocusUpId = nextFocusUp + button?.setOnCheckedChangeListener { _, _ -> val list = ArrayList() for ((sbutton, vvalidTypes) in pairList) { @@ -344,7 +344,13 @@ class HomeFragment : Fragment() { BottomSheetDialog(this) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - builder.setContentView(R.layout.home_select_mainpage) + val binding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( + builder.layoutInflater, + null, + false + ) + + builder.setContentView(binding.root) builder.show() builder.let { dialog -> val isMultiLang = getApiProviderLangSettings().let { set -> @@ -355,19 +361,13 @@ class HomeFragment : Fragment() { var currentApiName = selectedApiName var currentValidApis: MutableList = mutableListOf() - val preSelectedTypes = this.getKey>(HOME_PREF_HOMEPAGE) - ?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } - ?.toMutableList() - ?: mutableListOf(TvType.Movie, TvType.TvSeries) + val preSelectedTypes = DataStoreHelper.homePreference.toMutableList() - val cancelBtt = dialog.findViewById(R.id.cancel_btt) - val applyBtt = dialog.findViewById(R.id.apply_btt) - - cancelBtt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe() } - applyBtt?.setOnClickListener { + binding.applyBtt.setOnClickListener { if (currentApiName != selectedApiName) { currentApiName?.let(callback) } @@ -389,7 +389,7 @@ class HomeFragment : Fragment() { } fun updateList() { - this.setKey(HOME_PREF_HOMEPAGE, preSelectedTypes) + DataStoreHelper.homePreference = preSelectedTypes arrayAdapter.clear() currentValidApis = validAPIs.filter { api -> @@ -408,7 +408,7 @@ class HomeFragment : Fragment() { } bindChips( - dialog.home_select_group, + binding.tvtypesChipsScroll.tvtypesChips, preSelectedTypes, validAPIs.flatMap { it.supportedTypes }.distinct() ) { list -> @@ -423,6 +423,9 @@ class HomeFragment : Fragment() { private val homeViewModel: HomeViewModel by activityViewModels() + var binding: FragmentHomeBinding? = null + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -430,14 +433,26 @@ class HomeFragment : Fragment() { ): View? { //homeViewModel = // ViewModelProvider(this).get(HomeViewModel::class.java) + bottomSheetDialog?.ownShow() val layout = - if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home - return inflater.inflate(layout, container, false) + if (isLayout(TV or EMULATOR)) R.layout.fragment_home_tv else R.layout.fragment_home + val root = inflater.inflate(layout, container, false) + binding = try { + FragmentHomeBinding.bind(root) + } catch (t: Throwable) { + showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG) + logError(t) + null + } + + return root } override fun onDestroyView() { + bottomSheetDialog?.ownHide() + binding = null super.onDestroyView() } @@ -450,7 +465,7 @@ class HomeFragment : Fragment() { private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> - homeViewModel.loadAndCancel(api) + homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) } /*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf() @@ -467,196 +482,148 @@ class HomeFragment : Fragment() { fixGrid() } - fun bookmarksUpdated(_data : Boolean) { - reloadStored() - } - - override fun onResume() { - super.onResume() - reloadStored() - bookmarksUpdatedEvent += ::bookmarksUpdated - afterPluginsLoadedEvent += ::afterPluginsLoaded - mainPluginsLoadedEvent += ::afterMainPluginsLoaded - } - - override fun onStop() { - bookmarksUpdatedEvent -= ::bookmarksUpdated - afterPluginsLoadedEvent -= ::afterPluginsLoaded - mainPluginsLoadedEvent -= ::afterMainPluginsLoaded - super.onStop() - } - - private fun reloadStored() { - homeViewModel.loadResumeWatching() - val list = EnumSet.noneOf(WatchType::class.java) - getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let { - list.addAll(it) - } - homeViewModel.loadStoredData(list) - } - - private fun afterMainPluginsLoaded(unused: Boolean = false) { - loadHomePage(false) - } - - private fun afterPluginsLoaded(forceReload: Boolean) { - loadHomePage(forceReload) - } - - private fun loadHomePage(forceReload: Boolean) { - val apiName = context?.getKey(USER_SELECTED_HOMEPAGE_API) - - if (homeViewModel.apiName.value != apiName || apiName == null || forceReload) { - //println("Caught home: " + homeViewModel.apiName.value + " at " + apiName) - homeViewModel.loadAndCancel(apiName, forceReload) - } - } - - private fun homeHandleSearch(callback: SearchClickCallback) { - if (callback.action == SEARCH_ACTION_FOCUSED) { - //focusCallback(callback.card) - } else { - handleSearchClickCallback(activity, callback) - } - } - private var currentApiName: String? = null private var toggleRandomButton = false private var bottomSheetDialog: BottomSheetDialog? = null + // https://github.com/vivchar/RendererRecyclerViewAdapter/blob/185251ee9d94fb6eb3e063b00d646b745186c365/example/src/main/java/com/github/vivchar/example/pages/github/GithubFragment.kt#L32 + // cry about it, but this is android we are talking about, we cant do the most simple shit without making a global variable + private var instanceState: Bundle = Bundle() + private var homeMasterAdapter: HomeParentItemAdapterPreview? = null + @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) fixGrid() - home_change_api_loading?.setOnClickListener(apiChangeClickListener) - home_api_fab?.setOnClickListener(apiChangeClickListener) - home_random?.setOnClickListener { - if (listHomepageItems.isNotEmpty()) { - activity.loadSearchResult(listHomepageItems.random()) + binding?.apply { + //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) + //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) + homeApiFab.setOnClickListener(apiChangeClickListener) + homeChangeApi.setOnClickListener(apiChangeClickListener) + homeSwitchAccount.setOnClickListener { + activity?.showAccountSelectLinear() } + + homeRandom.setOnClickListener { + if (listHomepageItems.isNotEmpty()) { + activity.loadSearchResult(listHomepageItems.random()) + } + } + homeMasterAdapter = HomeParentItemAdapterPreview( + fragment = this@HomeFragment, + homeViewModel, + ) + homeMasterRecycler.adapter = homeMasterAdapter + //fixPaddingStatusbar(homeLoadingStatusbar) + + homeApiFab.isVisible = isLayout(PHONE) + + homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy > 0) { //check for scroll down + homeApiFab.shrink() // hide + homeRandom.shrink() + } else if (dy < -5) { + if (isLayout(PHONE)) { + homeApiFab.extend() // show + homeRandom.extend() + } + } + super.onScrolled(recyclerView, dx, dy) + } + }) + } + //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) toggleRandomButton = - settingsManager.getBoolean(getString(R.string.random_button_key), false) - home_random?.visibility = View.GONE - } - - observe(homeViewModel.preview) { preview -> - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setPreviewData( - preview - ) + settingsManager.getBoolean( + getString(R.string.random_button_key), + false + ) && isLayout(PHONE) + binding?.homeRandom?.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - home_api_fab?.text = apiName - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setApiName( - apiName - ) + binding?.homeApiFab?.text = apiName + binding?.homeChangeApi?.text = apiName } observe(homeViewModel.page) { data -> - when (data) { - is Resource.Success -> { - home_loading_shimmer?.stopShimmer() + binding?.apply { + when (data) { + is Resource.Success -> { + homeLoadingShimmer.stopShimmer() - val d = data.value - val mutableListOfResponse = mutableListOf() - listHomepageItems.clear() + val d = data.value + val mutableListOfResponse = mutableListOf() + listHomepageItems.clear() - (home_master_recycler?.adapter as? ParentItemAdapter)?.updateList( - d.values.toMutableList(), - home_master_recycler - ) - - home_loading?.isVisible = false - home_loading_error?.isVisible = false - home_master_recycler?.isVisible = true - //home_loaded?.isVisible = true - if (toggleRandomButton) { - //Flatten list - d.values.forEach { dlist -> - mutableListOfResponse.addAll(dlist.list.list) - } - listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - home_random?.isVisible = listHomepageItems.isNotEmpty() - } else { - home_random?.isGone = true - } - } - is Resource.Failure -> { - home_loading_shimmer?.stopShimmer() - - result_error_text.text = data.errorString - - home_reload_connectionerror.setOnClickListener(apiChangeClickListener) - - home_reload_connection_open_in_browser.setOnClickListener { view -> - val validAPIs = apis//.filter { api -> api.hasMainPage } - - view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> - Pair( - index, - api.name + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { + it.copy( + list = it.list.copy(list = it.list.list.toMutableList()) ) - }) { - try { - val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(validAPIs[itemId].mainUrl) - startActivity(i) - } catch (e: Exception) { - logError(e) + }.toMutableList()) + + homeLoading.isVisible = false + homeLoadingError.isVisible = false + homeMasterRecycler.isVisible = true + //home_loaded?.isVisible = true + if (toggleRandomButton) { + //Flatten list + d.values.forEach { dlist -> + mutableListOfResponse.addAll(dlist.list.list) + } + listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) + + homeRandom.isVisible = listHomepageItems.isNotEmpty() + } else { + homeRandom.isGone = true + } + } + + is Resource.Failure -> { + homeLoadingShimmer.stopShimmer() + resultErrorText.text = data.errorString + homeReloadConnectionerror.setOnClickListener(apiChangeClickListener) + homeReloadConnectionOpenInBrowser.setOnClickListener { view -> + val validAPIs = apis//.filter { api -> api.hasMainPage } + + view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> + Pair( + index, + api.name + ) + }) { + try { + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(validAPIs[itemId].mainUrl) + startActivity(i) + } catch (e: Exception) { + logError(e) + } } } + + homeLoading.isVisible = false + homeLoadingError.isVisible = true + homeMasterRecycler.isVisible = false + //home_loaded?.isVisible = false } - home_loading?.isVisible = false - home_loading_error?.isVisible = true - home_master_recycler?.isVisible = false - //home_loaded?.isVisible = false - } - is Resource.Loading -> { - (home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(listOf()) - home_loading_shimmer?.startShimmer() - home_loading?.isVisible = true - home_loading_error?.isVisible = false - home_master_recycler?.isVisible = false - //home_loaded?.isVisible = false - } - } - } - - - - observe(homeViewModel.availableWatchStatusTypes) { availableWatchStatusTypes -> - context?.setKey( - HOME_BOOKMARK_VALUE_LIST, - availableWatchStatusTypes.first.map { it.internalId }.toIntArray() - ) - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setAvailableWatchStatusTypes( - availableWatchStatusTypes - ) - } - - observe(homeViewModel.bookmarks) { data -> - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setBookmarkData( - data - ) - } - - observe(homeViewModel.resumeWatching) { resumeWatching -> - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setResumeWatchingData( - resumeWatching - ) - if (isTrueTvSettings()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ioSafe { - activity?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult }) + is Resource.Loading -> { + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf()) + homeLoadingShimmer.startShimmer() + homeLoading.isVisible = true + homeLoadingError.isVisible = false + homeMasterRecycler.isVisible = false + //home_loaded?.isVisible = false } } } @@ -665,72 +632,37 @@ class HomeFragment : Fragment() { //context?.fixPaddingStatusbarView(home_statusbar) //context?.fixPaddingStatusbar(home_padding) - context?.fixPaddingStatusbar(home_loading_statusbar) - home_master_recycler?.adapter = - HomeParentItemAdapterPreview(mutableListOf(), { callback -> - homeHandleSearch(callback) - }, { item -> - bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = { - homeViewModel.expandAndReturn(it) - }, dismissCallback = { - bottomSheetDialog = null - }) - }, { name -> - homeViewModel.expand(name) - }, { load -> - activity?.loadResult(load.response.url, load.response.apiName, load.action) - }, { - homeViewModel.loadMoreHomeScrollResponses() - }, { - apiChangeClickListener.onClick(it) - }, reloadStored = { - reloadStored() - }, loadStoredData = { - homeViewModel.loadStoredData(it) - }, { (isQuickSearch, text) -> - if (!isQuickSearch) { - QuickSearchFragment.pushSearch( - activity, - text, - currentApiName?.let { arrayOf(it) }) - } - }) - - reloadStored() - loadHomePage(false) - home_master_recycler?.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (dy > 0) { //check for scroll down - home_api_fab?.shrink() // hide - home_random?.shrink() - } else if (dy < -5) { - if (!isTvSettings()) { - home_api_fab?.extend() // show - home_random?.extend() - } - } - - super.onScrolled(recyclerView, dx, dy) + observeNullable(homeViewModel.popup) { item -> + if (item == null) { + bottomSheetDialog?.dismissSafe() + bottomSheetDialog = null + return@observeNullable } - }) + + // don't recreate + if (bottomSheetDialog != null) { + return@observeNullable + } + + val (items, delete) = item + + bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = { + homeViewModel.expandAndReturn(it) + }, dismissCallback = { + homeViewModel.popup(null) + bottomSheetDialog = null + }, deleteCallback = delete) + } + + homeViewModel.reloadStored() + homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false) + //loadHomePage(false) // nice profile pic on homepage //home_profile_picture_holder?.isVisible = false // just in case - if (isTvSettings()) { - home_api_fab?.isVisible = false - if (isTrueTvSettings()) { - home_change_api_loading?.isVisible = true - home_change_api_loading?.isFocusable = true - home_change_api_loading?.isFocusableInTouchMode = true - } - // home_bookmark_select?.isFocusable = true - // home_bookmark_select?.isFocusableInTouchMode = true - } else { - home_api_fab?.isVisible = true - home_change_api_loading?.isVisible = false - } + //TODO READD THIS /*for (syncApi in OAuth2Apis) { val login = syncApi.loginInfo() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index e6999c9e..8bc0aa28 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -1,52 +1,30 @@ package com.lagradost.cloudstream3.ui.home +import android.os.Build +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView -import androidx.transition.ChangeBounds -import androidx.transition.TransitionManager -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipDrawable -import com.lagradost.cloudstream3.APIHolder.getId -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.result.LinearListLayout -import com.lagradost.cloudstream3.ui.result.ResultViewModel2 -import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST +import com.lagradost.cloudstream3.databinding.HomepageParentBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback -import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView -import kotlinx.android.synthetic.main.activity_main_tv.* -import kotlinx.android.synthetic.main.activity_main_tv.view.* -import kotlinx.android.synthetic.main.fragment_home.* -import kotlinx.android.synthetic.main.fragment_home.view.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager -import kotlinx.android.synthetic.main.homepage_parent.view.* +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable class LoadClickCallback( val action: Int = 0, @@ -56,174 +34,89 @@ class LoadClickCallback( ) open class ParentItemAdapter( - private var items: MutableList, + open val fragment: Fragment, + id: Int, private val clickCallback: (SearchClickCallback) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val expandCallback: ((String) -> Unit)? = null, -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return ParentViewHolder( - LayoutInflater.from(parent.context).inflate( - if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent, - parent, - false - ), - clickCallback, - moreInfoClickCallback, - expandCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ParentViewHolder -> { - holder.bind(items[position]) - } - } - } - - override fun getItemCount(): Int { - return items.size - } - - override fun getItemId(position: Int): Long { - return items[position].list.name.hashCode().toLong() - } - - @JvmName("updateListHomePageList") - fun updateList(newList: List) { - updateList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } - .toMutableList()) - } - - @JvmName("updateListExpandableHomepageList") - fun updateList( - newList: MutableList, - recyclerView: RecyclerView? = null - ) { - // this - // 1. prevents deep copy that makes this.items == newList - // 2. filters out undesirable results - // 3. moves empty results to the bottom (sortedBy is a stable sort) - val new = - newList.map { it.copy(list = it.list.copy(list = it.list.list.filterSearchResponse())) } - .sortedBy { it.list.list.isEmpty() } - - val diffResult = DiffUtil.calculateDiff( - SearchDiffCallback(items, new) - ) - items.clear() - items.addAll(new) - - //val mAdapter = this - val delta = if (this@ParentItemAdapter is HomeParentItemAdapterPreview) { - headItems - } else { - 0 - } - - diffResult.dispatchUpdatesTo(object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - //notifyItemRangeChanged(position + delta, count) - notifyItemRangeInserted(position + delta, count) - } - - override fun onRemoved(position: Int, count: Int) { - notifyItemRangeRemoved(position + delta, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - notifyItemMoved(fromPosition + delta, toPosition + delta) - } - - override fun onChanged(_position: Int, count: Int, payload: Any?) { - - val position = _position + delta - - // I know kinda messy, what this does is using the update or bind instead of onCreateViewHolder -> bind - recyclerView?.apply { - // this loops every viewHolder in the recycle view and checks the position to see if it is within the update range - val missingUpdates = (position until (position + count)).toMutableSet() - for (i in 0 until itemCount) { - val child = getChildAt(i) ?: continue - val viewHolder = getChildViewHolder(child) ?: continue - if (viewHolder !is ParentViewHolder) continue - - val absolutePosition = viewHolder.bindingAdapterPosition - if (absolutePosition >= position && absolutePosition < position + count) { - val expand = items.getOrNull(absolutePosition - delta) ?: continue - missingUpdates -= absolutePosition - //println("Updating ${viewHolder.title.text} ($absolutePosition $position) -> ${expand.list.name}") - if (viewHolder.title.text == expand.list.name) { - viewHolder.update(expand) - } else { - viewHolder.bind(expand) - } - } - } - - // just in case some item did not get updated - for (i in missingUpdates) { - notifyItemChanged(i, payload) - } - } ?: run { - // in case we don't have a nice - notifyItemRangeChanged(position, count, payload) - } - } +) : BaseAdapter( + fragment, + id, + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.list.name == b.list.name }, + contentSame = { a, b -> + a.list.list == b.list.list }) - - //diffResult.dispatchUpdatesTo(this) - } - - class ParentViewHolder - constructor( - itemView: View, - private val clickCallback: (SearchClickCallback) -> Unit, - private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, - private val expandCallback: ((String) -> Unit)? = null, - ) : - RecyclerView.ViewHolder(itemView) { - val title: TextView = itemView.home_child_more_info - val recyclerView: RecyclerView = itemView.home_child_recyclerview - - fun update(expand: HomeViewModel.ExpandableHomepageList) { - val info = expand.list - (recyclerView.adapter as? HomeChildItemAdapter?)?.apply { - updateList(info.list.toMutableList()) - hasNext = expand.hasNext - } ?: run { - recyclerView.adapter = HomeChildItemAdapter( - info.list.toMutableList(), - clickCallback = clickCallback, - nextFocusUp = recyclerView.nextFocusUpId, - nextFocusDown = recyclerView.nextFocusDownId, - ).apply { - isHorizontal = info.isHorizontalImages - } - recyclerView.setLinearListLayout() - } +) { + data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { + override fun save(): Bundle = Bundle().apply { + val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview + putParcelable( + "value", + recyclerView?.layoutManager?.onSaveInstanceState() + ) + (recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView) } - fun bind(expand: HomeViewModel.ExpandableHomepageList) { - val info = expand.list - recyclerView.adapter = HomeChildItemAdapter( - info.list.toMutableList(), + override fun restore(state: Bundle) { + (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( + state.getSafeParcelable("value") + ) + } + } + + override fun submitList(list: List?) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }) + } + + override fun onUpdateContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { + val binding = holder.view + if (binding !is HomepageParentBinding) return + (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) + } + + override fun onBindContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { + val startFocus = R.id.nav_rail_view + val endFocus = FOCUS_SELF + val binding = holder.view + if (binding !is HomepageParentBinding) return + val info = item.list + binding.apply { + homeChildRecyclerview.adapter = HomeChildItemAdapter( + fragment = fragment, + id = id + position + 100, clickCallback = clickCallback, - nextFocusUp = recyclerView.nextFocusUpId, - nextFocusDown = recyclerView.nextFocusDownId, + nextFocusUp = homeChildRecyclerview.nextFocusUpId, + nextFocusDown = homeChildRecyclerview.nextFocusDownId, ).apply { isHorizontal = info.isHorizontalImages - hasNext = expand.hasNext + hasNext = item.hasNext + submitList(item.list.list) } - recyclerView.setLinearListLayout() - title.text = info.name + homeChildRecyclerview.setLinearListLayout( + isHorizontal = true, + nextLeft = startFocus, + nextRight = endFocus, + ) + homeChildMoreInfo.text = info.name - recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + homeChildRecyclerview.addOnScrollListener(object : + RecyclerView.OnScrollListener() { var expandCount = 0 - val name = expand.list.name + val name = item.list.name - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + override fun onScrollStateChanged( + recyclerView: RecyclerView, + newState: Int + ) { super.onScrollStateChanged(recyclerView, newState) val adapter = recyclerView.adapter @@ -247,27 +140,40 @@ open class ParentItemAdapter( }) //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() - if (!isTvSettings()) { - title.setOnClickListener { - moreInfoClickCallback.invoke(expand) + if (isLayout(PHONE)) { + homeChildMoreInfo.setOnClickListener { + moreInfoClickCallback.invoke(item) } } } } + + override fun onCreateContent(parent: ViewGroup): ParentItemHolder { + val layoutResId = when { + isLayout(TV) -> R.layout.homepage_parent_tv + isLayout(EMULATOR) -> R.layout.homepage_parent_emulator + else -> R.layout.homepage_parent + } + + val inflater = LayoutInflater.from(parent.context) + val binding = try { + HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false)) + } catch (t: Throwable) { + logError(t) + // just in case someone forgot we don't want to crash + HomepageParentBinding.inflate(inflater) + } + + return ParentItemHolder(binding) + } + + fun updateList(newList: List) { + submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } + .toMutableList()) + } } -class SearchDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].list.name == newList[newItemPosition].list.name - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - oldList[oldItemPosition] == newList[newItemPosition] -} \ No newline at end of file +@Suppress("DEPRECATION") +inline fun Bundle.getSafeParcelable(key: String): T? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key) + else getParcelable(key, T::class.java) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 94a1a526..339ef1e1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -1,658 +1,641 @@ package com.lagradost.cloudstream3.ui.home +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipDrawable -import com.lagradost.cloudstream3.APIHolder.getId +import com.google.android.material.chip.ChipGroup import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.databinding.FragmentHomeHeadBinding +import com.lagradost.cloudstream3.databinding.FragmentHomeHeadTvBinding import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugException +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear +import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST +import com.lagradost.cloudstream3.ui.result.getId +import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA import com.lagradost.cloudstream3.ui.search.SearchClickCallback -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.activity_main.view.* -import kotlinx.android.synthetic.main.fragment_home_head.view.* -import kotlinx.android.synthetic.main.fragment_home_head.view.home_bookmarked_child_recyclerview -import kotlinx.android.synthetic.main.fragment_home_head.view.home_watch_parent_item_title -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_bookmarked_holder -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_none_padding -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_plan_to_watch_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_completed_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_dropped_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_on_hold_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_watching_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_watch_child_recyclerview -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_watch_holder -import kotlinx.android.synthetic.main.toast.view.* +import com.lagradost.cloudstream3.utils.UIHelper.populateChips class HomeParentItemAdapterPreview( - items: MutableList, - val clickCallback: (SearchClickCallback) -> Unit, - private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, - expandCallback: ((String) -> Unit)? = null, - private val loadCallback: (LoadClickCallback) -> Unit, - private val loadMoreCallback: (() -> Unit), - private val changeHomePageCallback: ((View) -> Unit), - private val reloadStored: (() -> Unit), - private val loadStoredData: ((Set) -> Unit), - private val searchQueryCallback: ((Pair) -> Unit) -) : ParentItemAdapter(items, clickCallback, moreInfoClickCallback, expandCallback) { - private var previewData: Resource>> = Resource.Loading() - private var resumeWatchingData: List = listOf() - private var bookmarkData: Pair> = - false to listOf() - private var apiName: String = "NONE" + override val fragment: Fragment, + private val viewModel: HomeViewModel, +) : ParentItemAdapter(fragment, id = "HomeParentItemAdapterPreview".hashCode(), + clickCallback = { + viewModel.click(it) + }, moreInfoClickCallback = { + viewModel.popup(it) + }, expandCallback = { + viewModel.expand(it) + }) { + override val headers = 1 + override fun onCreateHeader(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = if (isLayout(TV or EMULATOR)) FragmentHomeHeadTvBinding.inflate( + inflater, + parent, + false + ) else FragmentHomeHeadBinding.inflate(inflater, parent, false) - val headItems = 1 + if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) { + binding.homeBookmarkParentItemMoreInfo.isVisible = true - private var availableWatchStatusTypes: Pair, Set> = - setOf() to setOf() + val marginInDp = 50 + val density = binding.horizontalScrollChips.context.resources.displayMetrics.density + val marginInPixels = (marginInDp * density).toInt() - fun setAvailableWatchStatusTypes(data: Pair, Set>) { - availableWatchStatusTypes = data - holder?.setAvailableWatchStatusTypes(data) - } - - companion object { - private const val VIEW_TYPE_HEADER = 2 - private const val VIEW_TYPE_ITEM = 1 - } - - fun setResumeWatchingData(resumeWatching: List) { - resumeWatchingData = resumeWatching - holder?.updateResume(resumeWatchingData) - } - - fun setPreviewData(preview: Resource>>) { - previewData = preview - holder?.updatePreview(preview) - } - - fun setApiName(name: String) { - apiName = name - holder?.updateApiName(name) - } - - fun setBookmarkData(data: Pair>) { - bookmarkData = data - holder?.updateBookmarks(data) - } - - override fun getItemViewType(position: Int) = when (position) { - 0 -> VIEW_TYPE_HEADER - else -> VIEW_TYPE_ITEM - } - - var holder: HeaderViewHolder? = null - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is HeaderViewHolder -> { - holder.updatePreview(previewData) - holder.updateResume(resumeWatchingData) - holder.updateBookmarks(bookmarkData) - holder.setAvailableWatchStatusTypes(availableWatchStatusTypes) - holder.updateApiName(apiName) - } - else -> super.onBindViewHolder(holder, position - 1) - } - } - - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - println("onCreateViewHolder $viewType") - return when (viewType) { - VIEW_TYPE_HEADER -> HeaderViewHolder( - LayoutInflater.from(parent.context).inflate( - if (isTvSettings()) R.layout.fragment_home_head_tv else R.layout.fragment_home_head, - parent, - false + val params = binding.horizontalScrollChips.layoutParams as ViewGroup.MarginLayoutParams + params.marginEnd = marginInPixels + binding.horizontalScrollChips.layoutParams = params + binding.homeWatchParentItemTitle.setCompoundDrawablesWithIntrinsicBounds( + null, + null, + ContextCompat.getDrawable( + parent.context, + R.drawable.ic_baseline_arrow_forward_24 ), - loadCallback, - loadMoreCallback, - changeHomePageCallback, - clickCallback, - reloadStored, - loadStoredData, - searchQueryCallback, - moreInfoClickCallback - ).also { - this.holder = it - } - VIEW_TYPE_ITEM -> super.onCreateViewHolder(parent, viewType) - else -> error("Unhandled viewType=$viewType") + null + ) } + + return HeaderViewHolder(binding, viewModel, fragment = fragment) } - override fun getItemCount(): Int { - return super.getItemCount() + headItems + override fun onBindHeader(holder: ViewHolderState) { + (holder as? HeaderViewHolder)?.bind() } - override fun getItemId(position: Int): Long { - if (position == 0) return previewData.hashCode().toLong() - return super.getItemId(position - headItems) - } + private class HeaderViewHolder( + val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment, + ) : + ViewHolderState(binding) { - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - when (holder) { - is HeaderViewHolder -> { - holder.onViewDetachedFromWindow() + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "resumeRecyclerView", + resumeRecyclerView.layoutManager?.onSaveInstanceState() + ) + putParcelable( + "bookmarkRecyclerView", + bookmarkRecyclerView.layoutManager?.onSaveInstanceState() + ) + //putInt("previewViewpager", previewViewpager.currentItem) } - else -> super.onViewDetachedFromWindow(holder) - } - } - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { - when (holder) { - is HeaderViewHolder -> { - holder.onViewAttachedToWindow() + override fun restore(state: Bundle) { + state.getSafeParcelable("resumeRecyclerView")?.let { recycle -> + resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle) + } + state.getSafeParcelable("bookmarkRecyclerView")?.let { recycle -> + bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle) } - else -> super.onViewAttachedToWindow(holder) } - } - - class HeaderViewHolder - constructor( - itemView: View, - private val clickCallback: ((LoadClickCallback) -> Unit)?, - private val loadMoreCallback: (() -> Unit), - private val changeHomePageCallback: ((View) -> Unit), - private val searchClickCallback: (SearchClickCallback) -> Unit, - private val reloadStored: () -> Unit, - private val loadStoredData: ((Set) -> Unit), - private val searchQueryCallback: ((Pair) -> Unit), - private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit - ) : RecyclerView.ViewHolder(itemView) { - private var previewAdapter: HomeScrollAdapter? = null - private val previewViewpager: ViewPager2? = itemView.home_preview_viewpager - private val previewHeader: FrameLayout? = itemView.home_preview - private val previewCallback: ViewPager2.OnPageChangeCallback = - object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - // home_search?.isIconified = true - //home_search?.isVisible = true - //home_search?.clearFocus() - - previewAdapter?.apply { - if (position >= itemCount - 1 && hasMoreItems) { - hasMoreItems = false // dont make two requests - loadMoreCallback() - //homeViewModel.loadMoreHomeScrollResponses() + val previewAdapter = HomeScrollAdapter(fragment = fragment) + private val resumeAdapter = HomeChildItemAdapter( + fragment, + id = "resumeAdapter".hashCode(), + nextFocusUp = itemView.nextFocusUpId, + nextFocusDown = itemView.nextFocusDownId + ) { callback -> + if (callback.action != SEARCH_ACTION_SHOW_METADATA) { + viewModel.click(callback) + return@HomeChildItemAdapter + } + callback.view.context?.getActivity()?.showOptionSelectStringRes( + callback.view, + callback.card.posterUrl, + listOf( + R.string.action_open_watching, + R.string.action_remove_watching + ), + listOf( + R.string.action_open_play, + R.string.action_open_watching, + R.string.action_remove_watching + ) + ) { (isTv, actionId) -> + when (actionId + if (isTv) 0 else 1) { + // play + 0 -> { + viewModel.click( + SearchClickCallback( + START_ACTION_RESUME_LATEST, + callback.view, + -1, + callback.card + ) + ) + } + //info + 1 -> { + viewModel.click( + SearchClickCallback( + SEARCH_ACTION_LOAD, + callback.view, + -1, + callback.card + ) + ) + } + // remove + 2 -> { + val card = callback.card + if (card is DataStoreHelper.ResumeWatchingResult) { + DataStoreHelper.removeLastWatched(card.parentId) + viewModel.reloadStored() } } - previewAdapter?.getItem(position) - ?.apply { - //itemView.home_preview_title_holder?.let { parent -> - // TransitionManager.beginDelayedTransition( - // parent, - // ChangeBounds() - // ) - //} - itemView.home_preview_description?.isGone = - this.plot.isNullOrBlank() - itemView.home_preview_description?.text = - this.plot ?: "" - itemView.home_preview_text?.text = this.name - itemView.home_preview_tags?.apply { - removeAllViews() - tags?.forEach { tag -> - val chip = Chip(context) - val chipDrawable = - ChipDrawable.createFromAttributes( - context, - null, - 0, - R.style.ChipFilledSemiTransparent - ) - chip.setChipDrawable(chipDrawable) - chip.text = tag - chip.isChecked = false - chip.isCheckable = false - chip.isFocusable = false - chip.isClickable = false - addView(chip) - } - } - itemView.home_preview_tags?.isGone = - tags.isNullOrEmpty() - itemView.home_preview_image?.setImage( - posterUrl, - posterHeaders + } + } + } + private val bookmarkAdapter = HomeChildItemAdapter( + fragment, + id = "bookmarkAdapter".hashCode(), + nextFocusUp = itemView.nextFocusUpId, + nextFocusDown = itemView.nextFocusDownId + ) { callback -> + if (callback.action != SEARCH_ACTION_SHOW_METADATA) { + viewModel.click(callback) + return@HomeChildItemAdapter + } + + (callback.view.context?.getActivity() as? MainActivity)?.loadPopup( + callback.card, + load = false + ) + /* + callback.view.context?.getActivity()?.showOptionSelectStringRes( + callback.view, + callback.card.posterUrl, + listOf( + R.string.action_open_watching, + R.string.action_remove_from_bookmarks, + ), + listOf( + R.string.action_open_play, + R.string.action_open_watching, + R.string.action_remove_from_bookmarks + ) + ) { (isTv, actionId) -> + when (actionId + if (isTv) 0 else 1) { // play + 0 -> { + viewModel.click( + SearchClickCallback( + START_ACTION_RESUME_LATEST, + callback.view, + -1, + callback.card ) - // itemView.home_preview_title?.text = name + ) + } - itemView.home_preview_play?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback( - START_ACTION_RESUME_LATEST, - view, - position, - this - ) - ) - } - itemView.home_preview_info?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback(0, view, position, this) - ) - } + 1 -> { // info + viewModel.click( + SearchClickCallback( + SEARCH_ACTION_LOAD, + callback.view, + -1, + callback.card + ) + ) + } - itemView.home_preview_play_btt?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback( - START_ACTION_RESUME_LATEST, - view, - position, - this - ) - ) - } + 2 -> { // remove + DataStoreHelper.setResultWatchState( + callback.card.id, + WatchType.NONE.internalId + ) + viewModel.reloadStored() + } + } + } + */ + } - // This makes the hidden next buttons only available when on the info button - // Otherwise you might be able to go to the next item without being at the info button - itemView.home_preview_info_btt?.setOnFocusChangeListener { _, hasFocus -> - itemView.home_preview_hidden_next_focus?.isFocusable = hasFocus - } - itemView.home_preview_play_btt?.setOnFocusChangeListener { _, hasFocus -> - itemView.home_preview_hidden_prev_focus?.isFocusable = hasFocus - } + private val previewViewpager: ViewPager2 = + itemView.findViewById(R.id.home_preview_viewpager) + private val previewViewpagerText: ViewGroup = + itemView.findViewById(R.id.home_preview_viewpager_text) - itemView.home_preview_info_btt?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback(0, view, position, this) - ) - } + // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview) + private val resumeHolder: View = itemView.findViewById(R.id.home_watch_holder) + private val resumeRecyclerView: RecyclerView = + itemView.findViewById(R.id.home_watch_child_recyclerview) + private val bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder) + private val bookmarkRecyclerView: RecyclerView = + itemView.findViewById(R.id.home_bookmarked_child_recyclerview) - itemView.home_preview_hidden_next_focus?.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - previewViewpager?.apply { - setCurrentItem(currentItem + 1, true) - } - itemView.home_preview_info_btt?.requestFocus() - } - } + private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account) + private val alternativeHomeAccount: View? = + itemView.findViewById(R.id.alternative_switch_account) - itemView.home_preview_hidden_prev_focus?.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - previewViewpager?.apply { - if (currentItem <= 0) { - nav_rail_view?.menu?.getItem(0)?.actionView?.requestFocus() - } else { - setCurrentItem(currentItem - 1, true) - itemView.home_preview_play_btt?.requestFocus() - } - } - } - } - // very ugly code, but I dont care - val watchType = - DataStoreHelper.getResultWatchState(this.getId()) - itemView.home_preview_bookmark?.setText(watchType.stringRes) - itemView.home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds( + private val topPadding: View? = itemView.findViewById(R.id.home_padding) + + private val alternativeAccountPadding: View? = + itemView.findViewById(R.id.alternative_account_padding) + + private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding) + + fun onSelect(item: LoadResponse, position: Int) { + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewDescription.isGone = + item.plot.isNullOrBlank() + homePreviewDescription.text = + item.plot ?: "" + + homePreviewText.text = item.name + populateChips( + homePreviewTags, + item.tags?.take(6) ?: emptyList(), + R.style.ChipFilledSemiTransparent + ) + + homePreviewTags.isGone = + item.tags.isNullOrEmpty() + + homePreviewPlayBtt.setOnClickListener { view -> + viewModel.click( + LoadClickCallback( + START_ACTION_RESUME_LATEST, + view, + position, + item + ) + ) + } + + homePreviewInfoBtt.setOnClickListener { view -> + viewModel.click( + LoadClickCallback(0, view, position, item) + ) + } + + } + (binding as? FragmentHomeHeadBinding)?.apply { + //homePreviewImage.setImage(item.posterUrl, item.posterHeaders) + + homePreviewPlay.setOnClickListener { view -> + viewModel.click( + LoadClickCallback( + START_ACTION_RESUME_LATEST, + view, + position, + item + ) + ) + } + + homePreviewInfo.setOnClickListener { view -> + viewModel.click( + LoadClickCallback(0, view, position, item) + ) + } + + // very ugly code, but I don't care + val id = item.getId() + val watchType = + DataStoreHelper.getResultWatchState(id) + homePreviewBookmark.setText(watchType.stringRes) + homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( + null, + ContextCompat.getDrawable( + homePreviewBookmark.context, + watchType.iconRes + ), + null, + null + ) + + homePreviewBookmark.setOnClickListener { fab -> + fab.context.getActivity()?.showBottomDialog( + WatchType.entries + .map { fab.context.getString(it.stringRes) } + .toList(), + DataStoreHelper.getResultWatchState(id).ordinal, + fab.context.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + val newValue = WatchType.entries[it] + + ResultViewModel2().updateWatchStatus( + newValue, + fab.context, + item + ) { statusChanged: Boolean -> + if (!statusChanged) return@updateWatchStatus + + homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( null, ContextCompat.getDrawable( - itemView.home_preview_bookmark.context, - watchType.iconRes + homePreviewBookmark.context, + newValue.iconRes ), null, null ) - itemView.home_preview_bookmark?.setOnClickListener { fab -> - fab.context.getActivity()?.showBottomDialog( - WatchType.values() - .map { fab.context.getString(it.stringRes) } - .toList(), - DataStoreHelper.getResultWatchState(this.getId()).ordinal, - fab.context.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - val newValue = WatchType.values()[it] - itemView.home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds( - null, - ContextCompat.getDrawable( - itemView.home_preview_bookmark.context, - newValue.iconRes - ), - null, - null - ) - itemView.home_preview_bookmark?.setText(newValue.stringRes) - - ResultViewModel2.updateWatchStatus( - this, - newValue - ) - reloadStored() - } - } + homePreviewBookmark.setText(newValue.stringRes) } + } + } + } + } + + private val previewCallback: ViewPager2.OnPageChangeCallback = + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + previewAdapter.apply { + if (position >= itemCount - 1 && hasMoreItems) { + hasMoreItems = false // don't make two requests + viewModel.loadMoreHomeScrollResponses() + } + } + val item = previewAdapter.getItemOrNull(position) ?: return + onSelect(item, position) } } - private var resumeAdapter: HomeChildItemAdapter? = null - private var resumeHolder: View? = itemView.home_watch_holder - private var resumeRecyclerView: RecyclerView? = itemView.home_watch_child_recyclerview - - private var bookmarkHolder: View? = itemView.home_bookmarked_holder - private var bookmarkAdapter: HomeChildItemAdapter? = null - private var bookmarkRecyclerView: RecyclerView? = - itemView.home_bookmarked_child_recyclerview - - fun onViewDetachedFromWindow() { - previewViewpager?.unregisterOnPageChangeCallback(previewCallback) + override fun onViewDetachedFromWindow() { + previewViewpager.unregisterOnPageChangeCallback(previewCallback) } - fun onViewAttachedToWindow() { - previewViewpager?.registerOnPageChangeCallback(previewCallback) - } - - private val toggleList = listOf( - Pair(itemView.home_type_watching_btt, WatchType.WATCHING), - Pair(itemView.home_type_completed_btt, WatchType.COMPLETED), - Pair(itemView.home_type_dropped_btt, WatchType.DROPPED), - Pair(itemView.home_type_on_hold_btt, WatchType.ONHOLD), - Pair(itemView.home_plan_to_watch_btt, WatchType.PLANTOWATCH), + private val toggleList = listOf>( + Pair(itemView.findViewById(R.id.home_type_watching_btt), WatchType.WATCHING), + Pair(itemView.findViewById(R.id.home_type_completed_btt), WatchType.COMPLETED), + Pair(itemView.findViewById(R.id.home_type_dropped_btt), WatchType.DROPPED), + Pair(itemView.findViewById(R.id.home_type_on_hold_btt), WatchType.ONHOLD), + Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH), ) + private val toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder) + + fun bind() = Unit + init { - itemView.home_preview_change_api?.setOnClickListener { view -> - changeHomePageCallback(view) - } - itemView.home_preview_change_api2?.setOnClickListener { view -> - changeHomePageCallback(view) - } + previewViewpager.setPageTransformer(HomeScrollTransformer()) - previewViewpager?.apply { - //if (!isTvSettings()) - setPageTransformer(HomeScrollTransformer()) - //else - // setPageTransformer(null) + previewViewpager.adapter = previewAdapter + resumeRecyclerView.adapter = resumeAdapter + bookmarkRecyclerView.adapter = bookmarkAdapter - if (adapter == null) - adapter = HomeScrollAdapter( - if (isTvSettings()) R.layout.home_scroll_view_tv else R.layout.home_scroll_view, - if (isTvSettings()) true else null - ) - } - previewAdapter = previewViewpager?.adapter as? HomeScrollAdapter? - // previewViewpager?.registerOnPageChangeCallback(previewCallback) + resumeRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) + bookmarkRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) - if (resumeAdapter == null) { - resumeRecyclerView?.adapter = HomeChildItemAdapter( - ArrayList(), - nextFocusUp = itemView.nextFocusUpId, - nextFocusDown = itemView.nextFocusDownId - ) { callback -> - if (callback.action != SEARCH_ACTION_SHOW_METADATA) { - searchClickCallback(callback) - return@HomeChildItemAdapter - } - callback.view.context?.getActivity()?.showOptionSelectStringRes( - callback.view, - callback.card.posterUrl, - listOf( - R.string.action_open_watching, - R.string.action_remove_watching - ), - listOf( - R.string.action_open_play, - R.string.action_open_watching, - R.string.action_remove_watching - ) - ) { (isTv, actionId) -> - when (actionId + if (isTv) 0 else 1) { - // play - 0 -> { - searchClickCallback.invoke( - SearchClickCallback( - START_ACTION_RESUME_LATEST, - callback.view, - -1, - callback.card - ) - ) - reloadStored() - } - //info - 1 -> { - searchClickCallback( - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) - - reloadStored() - } - // remove - 2 -> { - val card = callback.card - if (card is DataStoreHelper.ResumeWatchingResult) { - DataStoreHelper.removeLastWatched(card.parentId) - reloadStored() - } - } - } - } - } - } - resumeAdapter = resumeRecyclerView?.adapter as? HomeChildItemAdapter - if (bookmarkAdapter == null) { - bookmarkRecyclerView?.adapter = HomeChildItemAdapter( - ArrayList(), - nextFocusUp = itemView.nextFocusUpId, - nextFocusDown = itemView.nextFocusDownId - ) { callback -> - if (callback.action != SEARCH_ACTION_SHOW_METADATA) { - searchClickCallback(callback) - return@HomeChildItemAdapter - } - callback.view.context?.getActivity()?.showOptionSelectStringRes( - callback.view, - callback.card.posterUrl, - listOf( - R.string.action_open_watching, - R.string.action_remove_from_bookmarks, - ), - listOf( - R.string.action_open_play, - R.string.action_open_watching, - R.string.action_remove_from_bookmarks - ) - ) { (isTv, actionId) -> - when (actionId + if (isTv) 0 else 1) { // play - 0 -> { - searchClickCallback.invoke( - SearchClickCallback( - START_ACTION_RESUME_LATEST, - callback.view, - -1, - callback.card - ) - ) - reloadStored() - } - 1 -> { // info - searchClickCallback( - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) - - reloadStored() - } - 2 -> { // remove - DataStoreHelper.setResultWatchState( - callback.card.id, - WatchType.NONE.internalId - ) - reloadStored() - } - } - } - } - } - bookmarkAdapter = bookmarkRecyclerView?.adapter as? HomeChildItemAdapter + fixPaddingStatusbarMargin(topPadding) for ((chip, watch) in toggleList) { - chip?.isChecked = false - chip?.setOnCheckedChangeListener { _, isChecked -> + chip.isChecked = false + chip.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { - loadStoredData( - setOf(watch) - // If we filter all buttons then two can be checked at the same time - // Revert this if you want to go back to multi selection -// toggleList.filter { it.first?.isChecked == true }.map { it.second }.toSet() - ) + viewModel.loadStoredData(setOf(watch)) } // Else if all are unchecked -> Do not load data - else if (toggleList.all { it.first?.isChecked != true }) { - loadStoredData(emptySet()) + else if (toggleList.all { !it.first.isChecked }) { + viewModel.loadStoredData(emptySet()) } } } - itemView.home_search?.context?.fixPaddingStatusbar(itemView.home_search) + homeAccount?.setOnClickListener { + activity?.showAccountSelectLinear() + } - itemView.home_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - searchQueryCallback.invoke(false to query) - //QuickSearchFragment.pushSearch(activity, query, currentApiName?.let { arrayOf(it) } - return true + alternativeHomeAccount?.setOnClickListener { + activity?.showAccountSelectLinear() + } + + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewChangeApi.setOnClickListener { view -> + view.context.selectHomepage(viewModel.repo?.name) { api -> + viewModel.loadAndCancel(api, forceReload = true, fromUI = true) + } } - override fun onQueryTextChange(newText: String): Boolean { - searchQueryCallback.invoke(true to newText) - //searchViewModel.quickSearch(newText) - return true + homePreviewSearchButton.setOnClickListener { _ -> + // Open blank screen. + viewModel.queryTextSubmit("") } - }) + + // This makes the hidden next buttons only available when on the info button + // Otherwise you might be able to go to the next item without being at the info button + homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus -> + homePreviewHiddenNextFocus.isFocusable = hasFocus + } + + homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus -> + homePreviewHiddenPrevFocus.isFocusable = hasFocus + } + + homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) return@setOnFocusChangeListener + previewViewpager.setCurrentItem(previewViewpager.currentItem + 1, true) + homePreviewInfoBtt.requestFocus() + } + + homePreviewHiddenPrevFocus.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) return@setOnFocusChangeListener + if (previewViewpager.currentItem <= 0) { + (activity as? MainActivity)?.binding?.navRailView?.requestFocus() + } else { + previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) + binding.homePreviewPlayBtt.requestFocus() + } + } + } + + (binding as? FragmentHomeHeadBinding)?.apply { + homeSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + viewModel.queryTextSubmit(query) + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + viewModel.queryTextChange(newText) + return true + } + }) + } } - fun updateApiName(name: String) { - itemView.home_preview_change_api2?.text = name - itemView.home_preview_change_api?.text = name - } - - fun updatePreview(preview: Resource>>) { - itemView.home_preview_change_api2?.isGone = preview is Resource.Success + private fun updatePreview(preview: Resource>>) { if (preview is Resource.Success) { - itemView.home_none_padding?.apply { + homeNonePadding.apply { val params = layoutParams params.height = 0 layoutParams = params } } else { - itemView.home_none_padding?.context?.fixPaddingStatusbarView(itemView.home_none_padding) + fixPaddingStatusbarView(homeNonePadding) } + when (preview) { is Resource.Success -> { - if (true != previewAdapter?.setItems( + previewAdapter.submitList(preview.value.second) + previewAdapter.hasMoreItems = preview.value.first + /*if (!.setItems( preview.value.second, preview.value.first ) ) { // this might seam weird and useless, however this prevents a very weird andrid bug were the viewpager is not rendered properly // I have no idea why that happens, but this is my ducktape solution - previewViewpager?.setCurrentItem(0, false) - previewViewpager?.beginFakeDrag() - previewViewpager?.fakeDragBy(1f) - previewViewpager?.endFakeDrag() + previewViewpager.setCurrentItem(0, false) + previewViewpager.beginFakeDrag() + previewViewpager.fakeDragBy(1f) + previewViewpager.endFakeDrag() previewCallback.onPageSelected(0) - previewHeader?.isVisible = true - } + //previewHeader.isVisible = true + }*/ + + previewViewpager.isVisible = true + previewViewpagerText.isVisible = true + alternativeAccountPadding?.isVisible = false } + else -> { - previewAdapter?.setItems(listOf(), false) - previewViewpager?.setCurrentItem(0, false) - previewHeader?.isVisible = false + previewAdapter.submitList(listOf()) + previewViewpager.setCurrentItem(0, false) + previewViewpager.isVisible = false + previewViewpagerText.isVisible = false + alternativeAccountPadding?.isVisible = true + //previewHeader.isVisible = false } } - // previewViewpager?.postDelayed({ previewViewpager?.scr(100, 0) }, 1000) - //previewViewpager?.postInvalidate() } - fun updateResume(resumeWatching: List) { - resumeHolder?.isVisible = resumeWatching.isNotEmpty() - resumeAdapter?.updateList(resumeWatching) + private fun updateResume(resumeWatching: List) { + resumeHolder.isVisible = resumeWatching.isNotEmpty() + resumeAdapter.submitList(resumeWatching) - if (!isTvSettings()) { - itemView.home_watch_parent_item_title?.setOnClickListener { - moreInfoClickCallback.invoke( + if ( + binding is FragmentHomeHeadBinding || + binding is FragmentHomeHeadTvBinding && + isLayout(EMULATOR) + ) { + val title = (binding as? FragmentHomeHeadBinding)?.homeWatchParentItemTitle + ?: (binding as? FragmentHomeHeadTvBinding)?.homeWatchParentItemTitle + + title?.setOnClickListener { + viewModel.popup( HomeViewModel.ExpandableHomepageList( HomePageList( - itemView.home_watch_parent_item_title?.text.toString(), + title.text.toString(), resumeWatching, false ), 1, false - ) + ), + deleteCallback = { + viewModel.deleteResumeWatching() + } ) } } } - fun updateBookmarks(data: Pair>) { - bookmarkHolder?.isVisible = data.first - bookmarkAdapter?.updateList(data.second) - if (!isTvSettings()) { - itemView.home_bookmark_parent_item_title?.setOnClickListener { - val items = toggleList.mapNotNull { it.first }.filter { it.isChecked } + private fun updateBookmarks(data: Pair>) { + val (visible, list) = data + bookmarkHolder.isVisible = visible + bookmarkAdapter.submitList(list) + + if ( + binding is FragmentHomeHeadBinding || + binding is FragmentHomeHeadTvBinding && + isLayout(EMULATOR) + ) { + val title = (binding as? FragmentHomeHeadBinding)?.homeBookmarkParentItemTitle + ?: (binding as? FragmentHomeHeadTvBinding)?.homeBookmarkParentItemTitle + + title?.setOnClickListener { + val items = toggleList.map { it.first }.filter { it.isChecked } if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog val textSum = items .mapNotNull { it.text }.joinToString() - moreInfoClickCallback.invoke( + viewModel.popup( HomeViewModel.ExpandableHomepageList( HomePageList( textSum, - data.second, + list, false ), 1, false - ) + ), deleteCallback = { + viewModel.deleteBookmarks(list) + } ) } } } - fun setAvailableWatchStatusTypes(availableWatchStatusTypes: Pair, Set>) { - for ((chip, watch) in toggleList) { - chip?.apply { - isVisible = availableWatchStatusTypes.second.contains(watch) - isChecked = availableWatchStatusTypes.first.contains(watch) + override fun onViewAttachedToWindow() { + previewViewpager.registerOnPageChangeCallback(previewCallback) + + binding.root.findViewTreeLifecycleOwner()?.apply { + observe(viewModel.preview) { + updatePreview(it) } - } + if (binding is FragmentHomeHeadTvBinding) { + observe(viewModel.apiName) { name -> + binding.homePreviewChangeApi.text = name + } + } + observe(viewModel.resumeWatching) { + updateResume(it) + } + observe(viewModel.bookmarks) { + updateBookmarks(it) + } + observe(viewModel.availableWatchStatusTypes) { (checked, visible) -> + for ((chip, watch) in toggleList) { + chip.apply { + isVisible = visible.contains(watch) + isChecked = checked.contains(watch) + } + } + toggleListHolder?.isGone = visible.isEmpty() + } + } ?: debugException { "Expected findViewTreeLifecycleOwner" } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt index f296e53d..29186e83 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt @@ -2,102 +2,63 @@ package com.lagradost.cloudstream3.ui.home import android.content.res.Configuration import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import androidx.annotation.LayoutRes import androidx.core.view.isGone -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.Fragment import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding +import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.fragment_home_head_tv.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.* -import kotlinx.android.synthetic.main.home_scroll_view.view.* - class HomeScrollAdapter( - @LayoutRes val layout: Int = R.layout.home_scroll_view, - private val forceHorizontalPosters: Boolean? = null -) : RecyclerView.Adapter() { - private var items: MutableList = mutableListOf() + fragment: Fragment +) : NoStateAdapter(fragment) { var hasMoreItems: Boolean = false - fun getItem(position: Int): LoadResponse? { - return items.getOrNull(position) + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = if (isLayout(TV or EMULATOR)) { + HomeScrollViewTvBinding.inflate(inflater, parent, false) + } else { + HomeScrollViewBinding.inflate(inflater, parent, false) + } + + return ViewHolderState(binding) } - fun setItems(newItems: List, hasNext: Boolean): Boolean { - val isSame = newItems.firstOrNull()?.url == items.firstOrNull()?.url - hasMoreItems = hasNext + override fun onBindContent( + holder: ViewHolderState, + item: LoadResponse, + position: Int, + ) { + val binding = holder.view + val itemView = holder.itemView + val isHorizontal = + binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val diffResult = DiffUtil.calculateDiff( - HomeScrollDiffCallback(this.items, newItems) - ) + val posterUrl = + if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl + ?: item.backgroundPosterUrl - items.clear() - items.addAll(newItems) + when (binding) { + is HomeScrollViewBinding -> { + binding.homeScrollPreview.setImage(posterUrl) + binding.homeScrollPreviewTags.apply { + text = item.tags?.joinToString(" • ") ?: "" + isGone = item.tags.isNullOrEmpty() + maxLines = 2 + } + binding.homeScrollPreviewTitle.text = item.name + } - - diffResult.dispatchUpdatesTo(this) - - return isSame - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - forceHorizontalPosters - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(items[position]) + is HomeScrollViewTvBinding -> { + binding.homeScrollPreview.setImage(posterUrl) } } } - - class CardViewHolder - constructor( - itemView: View, - private val forceHorizontalPosters: Boolean? = null - ) : - RecyclerView.ViewHolder(itemView) { - - fun bind(card: LoadResponse) { - card.apply { - val isHorizontal = - (forceHorizontalPosters == true) || ((forceHorizontalPosters != false) && itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) - - val posterUrl = if (isHorizontal) backgroundPosterUrl ?: posterUrl else posterUrl - ?: backgroundPosterUrl - itemView.home_scroll_preview_tags?.text = tags?.joinToString(" • ") ?: "" - itemView.home_scroll_preview_tags?.isGone = tags.isNullOrEmpty() - itemView.home_scroll_preview?.setImage(posterUrl, posterHeaders) - itemView.home_scroll_preview_title?.text = name - } - } - } - - class HomeScrollDiffCallback( - private val oldList: List, - private val newList: List - ) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].url == newList[newItemPosition].url - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] - } - - override fun getItemCount(): Int { - return items.size - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index edf58008..24ca4df2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -1,38 +1,59 @@ package com.lagradost.cloudstream3.ui.home +import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED +import com.lagradost.cloudstream3.ui.search.SearchClickCallback +import com.lagradost.cloudstream3.ui.search.SearchHelper +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.addProgramsToContinueWatching +import com.lagradost.cloudstream3.utils.AppContextUtils.filterHomePageListByFilmQuality +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.VideoDownloadHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext -import java.util.* +import java.util.EnumSet +import java.util.concurrent.CopyOnWriteArrayList import kotlin.collections.set class HomeViewModel : ViewModel() { @@ -72,7 +93,17 @@ class HomeViewModel : ViewModel() { } } - private var repo: APIRepository? = null + fun deleteResumeWatching() { + deleteAllResumeStateIds() + loadResumeWatching() + } + + fun deleteBookmarks(list: List) { + list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) } + loadStoredData() + } + + var repo: APIRepository? = null private val _apiName = MutableLiveData() val apiName: LiveData = _apiName @@ -83,7 +114,7 @@ class HomeViewModel : ViewModel() { private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { - return APIRepository(apis.first { it.hasMainPage }) + return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = @@ -95,14 +126,20 @@ class HomeViewModel : ViewModel() { private val _resumeWatching = MutableLiveData>() private val _preview = MutableLiveData>>>() - private val previewResponses = mutableListOf() + private val previewResponses = CopyOnWriteArrayList() private val previewResponsesAdded = mutableSetOf() val resumeWatching: LiveData> = _resumeWatching val preview: LiveData>>> = _preview - fun loadResumeWatching() = viewModelScope.launchSafe { + private fun loadResumeWatching() = viewModelScope.launchSafe { val resumeWatchingResult = getResumeWatching() + if (isLayout(TV) && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ioSafe { + // this WILL crash on non tvs, so keep this inside a try catch + activity?.addProgramsToContinueWatching(resumeWatchingResult) + } + } resumeWatchingResult?.let { _resumeWatching.postValue(it) } @@ -115,7 +152,7 @@ class HomeViewModel : ViewModel() { } }?.distinctBy { it.first } ?: return@launchSafe - val length = WatchType.values().size + val length = WatchType.entries.size val currentWatchTypes = mutableSetOf() for (watch in watchStatusIds) { @@ -128,6 +165,7 @@ class HomeViewModel : ViewModel() { currentWatchTypes.remove(WatchType.NONE) if (currentWatchTypes.size <= 0) { + DataStoreHelper.homeBookmarkedList = intArrayOf() _availableWatchStatusTypes.postValue(setOf() to setOf()) _bookmarks.postValue(Pair(false, ArrayList())) return@launchSafe @@ -136,12 +174,13 @@ class HomeViewModel : ViewModel() { val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first()) //if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first()) + DataStoreHelper.homeBookmarkedList = watchPrefNotNull.map { it.internalId }.toIntArray() _availableWatchStatusTypes.postValue( - Pair( - watchPrefNotNull, - currentWatchTypes, + + watchPrefNotNull to + currentWatchTypes, + ) - ) val list = withContext(Dispatchers.IO) { watchStatusIds.filter { watchPrefNotNull.contains(it.second) } @@ -152,8 +191,11 @@ class HomeViewModel : ViewModel() { } private var onGoingLoad: Job? = null - private fun loadAndCancel(api: MainAPI?) { + private var isCurrentlyLoadingName: String? = null + private fun loadAndCancel(api: MainAPI) { + //println("loaded ${api.name}") onGoingLoad?.cancel() + isCurrentlyLoadingName = api.name onGoingLoad = load(api) } @@ -255,12 +297,12 @@ class HomeViewModel : ViewModel() { } } - private fun load(api: MainAPI?) = ioSafe { - repo = if (api != null) { + private fun load(api: MainAPI): Job = ioSafe { + repo = //if (api != null) { APIRepository(api) - } else { - autoloadRepo() - } + //} else { + // autoloadRepo() + //} _apiName.postValue(repo?.name) _randomItems.postValue(listOf()) @@ -274,6 +316,7 @@ class HomeViewModel : ViewModel() { _page.postValue(Resource.Loading()) _preview.postValue(Resource.Loading()) + // cancel the current preview expand as that is no longer relevant addJob?.cancel() when (val data = repo?.getMainPage(1, null)) { @@ -285,7 +328,13 @@ class HomeViewModel : ViewModel() { val filteredList = context?.filterHomePageListByFilmQuality(list) ?: list expandable[list.name] = - ExpandableHomepageList(filteredList, 1, home.hasNext) + ExpandableHomepageList( + filteredList.copy( + list = CopyOnWriteArrayList( + filteredList.list + ) + ), 1, home.hasNext + ) } } @@ -300,8 +349,7 @@ class HomeViewModel : ViewModel() { val currentList = items.shuffled().filter { it.list.isNotEmpty() } .flatMap { it.list } - .distinctBy { it.url } - .toList() + .distinctBy { it.url }.toList() if (currentList.isNotEmpty()) { val randomItems = @@ -337,41 +385,142 @@ class HomeViewModel : ViewModel() { logError(e) } } + is Resource.Failure -> { + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _page.postValue(data!!) + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _preview.postValue(data!!) } + else -> Unit } + isCurrentlyLoadingName = null + } + + fun click(callback: SearchClickCallback) { + if (callback.action != SEARCH_ACTION_FOCUSED) { + SearchHelper.handleSearchClickCallback(callback) + } } - fun loadAndCancel(preferredApiName: String?, forceReload: Boolean = true) = - viewModelScope.launchSafe { + + private val _popup = MutableLiveData Unit)?>?>(null) + val popup: LiveData Unit)?>?> = _popup + + fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) { + if (list == null) + _popup.postValue(null) + else + _popup.postValue(list to deleteCallback) + } + + private fun bookmarksUpdated(unused: Boolean) { + reloadStored() + } + + private fun afterPluginsLoaded(forceReload: Boolean) { + loadAndCancel(DataStoreHelper.currentHomePage, forceReload) + } + + private fun afterMainPluginsLoaded(unused: Boolean = false) { + loadAndCancel(DataStoreHelper.currentHomePage, false) + } + + private fun reloadHome(unused: Boolean = false) { + loadAndCancel(DataStoreHelper.currentHomePage, true) + } + + init { + MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated + MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded + MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded + MainActivity.reloadHomeEvent += ::reloadHome + } + + override fun onCleared() { + MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated + MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded + MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded + MainActivity.reloadHomeEvent -= ::reloadHome + super.onCleared() + } + + fun queryTextSubmit(query: String) { + QuickSearchFragment.pushSearch( + query, + repo?.name?.let { arrayOf(it) }) + } + + fun queryTextChange(newText: String) { + // do nothing + } + + fun loadStoredData() { + val list = EnumSet.noneOf(WatchType::class.java) + DataStoreHelper.homeBookmarkedList.map { WatchType.fromInternalId(it) }.let { + list.addAll(it) + } + loadStoredData(list) + } + + fun reloadStored() { + loadResumeWatching() + loadStoredData() + } + + fun click(load: LoadClickCallback) { + loadResult(load.response.url, load.response.apiName, load.action) + } + + // only save the key if it is from UI, as we don't want internal functions changing the setting + fun loadAndCancel( + preferredApiName: String?, + forceReload: Boolean = true, + fromUI: Boolean = false + ) = + ioSafe { + //println("trying to load $preferredApiName") // Since plugins are loaded in stages this function can get called multiple times. // The issue with this is that the homepage may be fetched multiple times while the first request is loading - val api = getApiFromNameNull(preferredApiName) - if (!forceReload && api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true) { - return@launchSafe + // api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true + val currentPage = page.value + + // if we don't need to reload and we have a valid homepage or currently loading the same thing then return + val currentLoading = isCurrentlyLoadingName + if (!forceReload && (currentPage is Resource.Success && currentPage.value.isNotEmpty() || (currentLoading != null && currentLoading == preferredApiName))) { + return@ioSafe } + val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { - setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name) + // just set to random + if (fromUI) DataStoreHelper.currentHomePage = noneApi.name loadAndCancel(noneApi) } else if (preferredApiName == randomApi.name) { + // randomize the api, if none exist like if not loaded or not installed + // then use nothing val validAPIs = context?.filterProviderByPreferredMedia() if (validAPIs.isNullOrEmpty()) { - // Do not set USER_SELECTED_HOMEPAGE_API when there is no plugins loaded loadAndCancel(noneApi) } else { val apiRandom = validAPIs.random() loadAndCancel(apiRandom) - setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name) + if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name } - // If the plugin isn't loaded yet. (Does not set the key) } else if (api == null) { - loadAndCancel(noneApi) + // API is not found aka not loaded or removed, post the loading + // progress if waiting for plugins, otherwise nothing + if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) { + loadAndCancel(noneApi) + } else { + _page.postValue(Resource.Loading()) + if (preferredApiName != null) + _apiName.postValue(preferredApiName!!) + } } else { - setKey(USER_SELECTED_HOMEPAGE_API, api.name) + // if the api is found, then set it to it and save key + if (fromUI) DataStoreHelper.currentHomePage = api.name loadAndCancel(api) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index d7c06c4e..5b240693 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -1,50 +1,73 @@ package com.lagradost.cloudstream3.ui.library +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper -import androidx.fragment.app.Fragment +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS +import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS import android.view.animation.AlphaAnimation +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView +import androidx.core.view.allViews +import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import kotlinx.android.synthetic.main.fragment_library.* +import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" enum class LibraryOpenerType(@StringRes val stringRes: Int) { - Default(R.string.default_subtitles), // TODO FIX AFTER MERGE + Default(R.string.action_default), Provider(R.string.none), Browser(R.string.browser), Search(R.string.search), @@ -63,6 +86,8 @@ data class ProviderLibraryData( class LibraryFragment : Fragment() { companion object { + + val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -73,40 +98,85 @@ class LibraryFragment : Fragment() { private val libraryViewModel: LibraryViewModel by activityViewModels() + var binding: FragmentLibraryBinding? = null + private var toggleRandomButton = false + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_library, container, false) + ): View { + val layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library + val root = inflater.inflate(layout, container, false) + binding = try { + FragmentLibraryBinding.bind(root) + } catch (t: Throwable) { + CommonActivity.showToast( + txt(R.string.unable_to_inflate, t.message ?: ""), + Toast.LENGTH_LONG + ) + logError(t) + null + } + + return root + + //return inflater.inflate(R.layout.fragment_library, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() } override fun onSaveInstanceState(outState: Bundle) { - viewpager?.currentItem?.let { currentItem -> + binding?.viewpager?.currentItem?.let { currentItem -> outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) } super.onSaveInstanceState(outState) } + private fun updateRandom() { + val position = libraryViewModel.currentPage.value ?: 0 + val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return + if (toggleRandomButton) { + listLibraryItems.clear() + listLibraryItems.addAll(pages[position].items) + binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty() + } else { + binding?.libraryRandom?.isGone = true + } + } + + @SuppressLint("ResourceType", "CutPasteId") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(search_status_bar_padding) + fixPaddingStatusbar(binding?.searchStatusBarPadding) - sort_fab?.setOnClickListener { - val methods = libraryViewModel.sortingMethods.map { - txt(it.stringRes).asString(view.context) + binding?.sortFab?.setOnClickListener(sortChangeClickListener) + binding?.librarySort?.setOnClickListener(sortChangeClickListener) + + binding?.libraryRoot?.findViewById(R.id.search_src_text)?.apply { + tag = "tv_no_focus_tag" + //Expand the Appbar when search bar is focused, fixing scroll up issue + setOnFocusChangeListener { _, _ -> + binding?.searchBar?.setExpanded(true) } - - activity?.showBottomDialog(methods, - libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), - txt(R.string.sort_by).asString(view.context), - false, - {}, - { - val method = libraryViewModel.sortingMethods[it] - libraryViewModel.sort(method) - }) } - main_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + // Set the color for the search exit icon to the correct theme text color + val searchExitIcon = + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) + + val searchCallback = Runnable { + val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable + libraryViewModel.sort(ListSorting.Query, newText) + } + + binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -122,14 +192,19 @@ class LibraryFragment : Fragment() { return true } - libraryViewModel.sort(ListSorting.Query, newText) + binding?.mainSearch?.removeCallbacks(searchCallback) + + // Delay the execution of the search operation by 1 second (adjust as needed) + // this prevents running search when the user is typing + binding?.mainSearch?.postDelayed(searchCallback, 1000) + return true } }) libraryViewModel.reloadPages(false) - list_selector?.setOnClickListener { + binding?.listSelector?.setOnClickListener { val items = libraryViewModel.availableApiNames val currentItem = libraryViewModel.currentApiName.value @@ -143,6 +218,25 @@ class LibraryFragment : Fragment() { } } + //Load value for toggling Random button. Hide at startup + context?.let { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) + toggleRandomButton = + settingsManager.getBoolean( + getString(R.string.random_button_key), + false + ) && isLayout(PHONE) + binding?.libraryRandom?.visibility = View.GONE + } + + binding?.libraryRandom?.setOnClickListener { + if (listLibraryItems.isNotEmpty()) { + val listLibraryItem = listLibraryItems.random() + libraryViewModel.currentSyncApi?.syncIdName?.let { + loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem) + } + } + } /** * Shows a plugin selection dialogue and saves the response @@ -152,12 +246,14 @@ class LibraryFragment : Fragment() { syncId: SyncIdName, apiName: String? = null, ) { - val availableProviders = allProviders.filter { - it.supportedSyncNames.contains(syncId) - }.map { it.name } + - // Add the api if it exists - (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList()) - + val availableProviders = synchronized(allProviders) { + allProviders.filter { + it.supportedSyncNames.contains(syncId) + }.map { it.name } + + // Add the api if it exists + (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } + ?: emptyList()) + } val baseOptions = listOf( LibraryOpenerType.Default, LibraryOpenerType.None, @@ -167,7 +263,7 @@ class LibraryFragment : Fragment() { val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders - val savedSelection = getKey(LIBRARY_FOLDER, key) + val savedSelection = getKey("$currentAccount/$LIBRARY_FOLDER", key) val selectedIndex = when { savedSelection == null -> 0 @@ -202,108 +298,84 @@ class LibraryFragment : Fragment() { } setKey( - LIBRARY_FOLDER, + "$currentAccount/$LIBRARY_FOLDER", key, savedData, ) } } - provider_selector?.setOnClickListener { + binding?.providerSelector?.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } - viewpager?.setPageTransformer(LibraryScrollTransformer()) - viewpager?.adapter = - viewpager.adapter ?: ViewpagerAdapter(mutableListOf(), { isScrollingDown: Boolean -> + binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) + + binding?.viewpager?.adapter = ViewpagerAdapter( + fragment = this, + { isScrollingDown: Boolean -> if (isScrollingDown) { - sort_fab?.shrink() + binding?.sortFab?.shrink() + binding?.libraryRandom?.shrink() } else { - sort_fab?.extend() + binding?.sortFab?.extend() + binding?.libraryRandom?.extend() } }) callback@{ searchClickCallback -> - // To prevent future accidents - debugAssert({ - searchClickCallback.card !is SyncAPI.LibraryItem - }, { - "searchClickCallback ${searchClickCallback.card} is not a LibraryItem" - }) + // To prevent future accidents + debugAssert({ + searchClickCallback.card !is SyncAPI.LibraryItem + }, { + "searchClickCallback ${searchClickCallback.card} is not a LibraryItem" + }) - val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId - val syncName = - libraryViewModel.currentSyncApi?.syncIdName ?: return@callback + val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId + val syncName = + libraryViewModel.currentSyncApi?.syncIdName ?: return@callback - when (searchClickCallback.action) { - SEARCH_ACTION_SHOW_METADATA -> { - activity?.showPluginSelectionDialog( + when (searchClickCallback.action) { + SEARCH_ACTION_SHOW_METADATA -> { + (activity as? MainActivity)?.loadPopup( + searchClickCallback.card, + load = false + ) + /*activity?.showPluginSelectionDialog( syncId, syncName, searchClickCallback.card.apiName - ) - } + )*/ + } - SEARCH_ACTION_LOAD -> { - // This basically first selects the individual opener and if that is default then - // selects the whole list opener - val savedListSelection = - getKey(LIBRARY_FOLDER, syncName.name) - val savedSelection = getKey(LIBRARY_FOLDER, syncId).takeIf { - it?.openType != LibraryOpenerType.Default - } ?: savedListSelection - - when (savedSelection?.openType) { - null, LibraryOpenerType.Default -> { - // Prevents opening MAL/AniList as a provider - if (APIHolder.getApiFromNameNull(searchClickCallback.card.apiName) != null) { - activity?.loadSearchResult( - searchClickCallback.card - ) - } else { - // Search when no provider can open - QuickSearchFragment.pushSearch( - activity, - searchClickCallback.card.name - ) - } - } - LibraryOpenerType.None -> {} - LibraryOpenerType.Provider -> - savedSelection.providerData?.apiName?.let { apiName -> - activity?.loadResult( - searchClickCallback.card.url, - apiName, - ) - } - LibraryOpenerType.Browser -> - openBrowser(searchClickCallback.card.url) - LibraryOpenerType.Search -> { - QuickSearchFragment.pushSearch( - activity, - searchClickCallback.card.name - ) - } - } - } + SEARCH_ACTION_LOAD -> { + loadLibraryItem(syncName, syncId, searchClickCallback.card) } } + } - viewpager?.offscreenPageLimit = 2 - viewpager?.reduceDragSensitivity() + binding?.apply { + viewpager.offscreenPageLimit = 2 + viewpager.reduceDragSensitivity() + searchBar.setExpanded(true) + } val startLoading = Runnable { - gridview?.numColumns = context?.getSpanCount() ?: 3 - gridview?.adapter = - context?.let { LoadingPosterAdapter(it, 6 * 3) } - library_loading_overlay?.isVisible = true - library_loading_shimmer?.startShimmer() - empty_list_textview?.isVisible = false + binding?.apply { + gridview.numColumns = context?.getSpanCount() ?: 3 + gridview.adapter = + context?.let { LoadingPosterAdapter(it, 6 * 3) } + libraryLoadingOverlay.isVisible = true + libraryLoadingShimmer.startShimmer() + emptyListTextview.isVisible = false + } } val stopLoading = Runnable { - gridview?.adapter = null - library_loading_overlay?.isVisible = false - library_loading_shimmer?.stopShimmer() + binding?.apply { + gridview.adapter = null + libraryLoadingOverlay.isVisible = false + libraryLoadingShimmer.stopShimmer() + } } val handler = Handler(Looper.getMainLooper()) @@ -314,65 +386,109 @@ class LibraryFragment : Fragment() { handler.removeCallbacks(startLoading) val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } - empty_list_textview?.isVisible = showNotice - if (showNotice) { - if (libraryViewModel.availableApiNames.size > 1) { - empty_list_textview?.setText(R.string.empty_library_logged_in_message) - } else { - empty_list_textview?.setText(R.string.empty_library_no_accounts_message) + + binding?.apply { + emptyListTextview.isVisible = showNotice + if (showNotice) { + if (libraryViewModel.availableApiNames.size > 1) { + emptyListTextview.setText(R.string.empty_library_logged_in_message) + } else { + emptyListTextview.setText(R.string.empty_library_no_accounts_message) + } } + + (viewpager.adapter as? ViewpagerAdapter)?.submitList(pages.map { + it.copy( + items = CopyOnWriteArrayList(it.items) + ) + }) + //fix focus on the viewpager itself + (viewpager.getChildAt(0) as RecyclerView).apply { + tag = "tv_no_focus_tag" + //isFocusable = false + } + + // Using notifyItemRangeChanged keeps the animations when sorting + /*viewpager.adapter?.notifyItemRangeChanged( + 0, + viewpager.adapter?.itemCount ?: 0 + )*/ + + libraryViewModel.currentPage.value?.let { page -> + binding?.viewpager?.setCurrentItem(page, false) + } + + updateRandom() + + // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating + // Without this there would be a flashing effect: + // loading -> show old viewpager -> black screen -> show new viewpager + handler.postDelayed(stopLoading, 300) + + savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos -> + if (currentPos < 0) return@let + viewpager.setCurrentItem(currentPos, false) + // Using remove() sets the key to 0 instead of removing it + savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1) + } + + // Since the animation to scroll multiple items is so much its better to just hide + // the viewpager a bit while the fastest animation is running + fun hideViewpager(distance: Int) { + if (distance < 3) return + + val hideAnimation = AlphaAnimation(1f, 0f).apply { + duration = distance * 50L + fillAfter = true + } + val showAnimation = AlphaAnimation(0f, 1f).apply { + duration = distance * 50L + startOffset = distance * 100L + fillAfter = true + } + viewpager.startAnimation(hideAnimation) + viewpager.startAnimation(showAnimation) + } + + TabLayoutMediator( + libraryTabLayout, + viewpager, + ) { tab, position -> + tab.text = pages.getOrNull(position)?.title?.asStringNull(context) + tab.view.tag = "tv_no_focus_tag" + tab.view.nextFocusDownId = R.id.search_result_root + + tab.view.setOnClickListener { + val currentItem = + binding?.viewpager?.currentItem ?: return@setOnClickListener + val distance = abs(position - currentItem) + hideViewpager(distance) + } + //Expand the appBar on tab focus + tab.view.setOnFocusChangeListener { _, _ -> + binding?.searchBar?.setExpanded(true) + } + }.attach() + + binding?.libraryTabLayout?.addOnTabSelectedListener(object : + TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + binding?.libraryTabLayout?.selectedTabPosition?.let { page -> + libraryViewModel.switchPage(page) + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit + override fun onTabReselected(tab: TabLayout.Tab?) = Unit + }) } - - (viewpager.adapter as? ViewpagerAdapter)?.pages = pages - // Using notifyItemRangeChanged keeps the animations when sorting - viewpager.adapter?.notifyItemRangeChanged(0, viewpager.adapter?.itemCount ?: 0) - - // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating - // Without this there would be a flashing effect: - // loading -> show old viewpager -> black screen -> show new viewpager - handler.postDelayed(stopLoading, 300) - - savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos -> - if (currentPos < 0) return@let - viewpager?.setCurrentItem(currentPos, false) - // Using remove() sets the key to 0 instead of removing it - savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1) - } - - // Since the animation to scroll multiple items is so much its better to just hide - // the viewpager a bit while the fastest animation is running - fun hideViewpager(distance: Int) { - if (distance < 3) return - - val hideAnimation = AlphaAnimation(1f, 0f).apply { - duration = distance * 50L - fillAfter = true - } - val showAnimation = AlphaAnimation(0f, 1f).apply { - duration = distance * 50L - startOffset = distance * 100L - fillAfter = true - } - viewpager?.startAnimation(hideAnimation) - viewpager?.startAnimation(showAnimation) - } - - TabLayoutMediator( - library_tab_layout, - viewpager, - ) { tab, position -> - tab.text = pages.getOrNull(position)?.title?.asStringNull(context) - tab.view.setOnClickListener { - val currentItem = viewpager?.currentItem ?: return@setOnClickListener - val distance = abs(position - currentItem) - hideViewpager(distance) - } - }.attach() } + is Resource.Loading -> { // Only start loading after 200ms to prevent loading cached lists handler.postDelayed(startLoading, 200) } + is Resource.Failure -> { stopLoading.run() // No user indication it failed :( @@ -380,16 +496,108 @@ class LibraryFragment : Fragment() { } } } + + observe(libraryViewModel.currentPage) { position -> + updateRandom() + val all = binding?.viewpager?.allViews?.toList() + ?.filterIsInstance() + + all?.forEach { view -> + view.isVisible = view.tag == position + view.isFocusable = view.tag == position + + if (view.tag == position) + view.descendantFocusability = FOCUS_AFTER_DESCENDANTS + else + view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS + } + } + + /*binding?.viewpager?.registerOnPageChangeCallback(object : + ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + + super.onPageSelected(position) + } + })*/ } + private fun loadLibraryItem( + syncName: SyncIdName, + syncId: String, + card: SearchResponse + ) { + // This basically first selects the individual opener and if that is default then + // selects the whole list opener + val savedListSelection = + getKey("$currentAccount/$LIBRARY_FOLDER", syncName.name) + + val savedSelection = getKey( + "$currentAccount/$LIBRARY_FOLDER", + syncId + ).takeIf { + it?.openType != LibraryOpenerType.Default + } ?: savedListSelection + + when (savedSelection?.openType) { + null, LibraryOpenerType.Default -> { + // Prevents opening MAL/AniList as a provider + if (APIHolder.getApiFromNameNull(card.apiName) != null) { + activity?.loadSearchResult( + card + ) + } else { + // Search when no provider can open + QuickSearchFragment.pushSearch( + activity, + card.name + ) + } + } + + LibraryOpenerType.None -> {} + LibraryOpenerType.Provider -> + savedSelection.providerData?.apiName?.let { apiName -> + activity?.loadResult( + card.url, + apiName, + ) + } + + LibraryOpenerType.Browser -> + openBrowser(card.url) + + LibraryOpenerType.Search -> { + QuickSearchFragment.pushSearch( + activity, + card.name + ) + } + } + + } + + @SuppressLint("NotifyDataSetChanged") override fun onConfigurationChanged(newConfig: Configuration) { - (viewpager.adapter as? ViewpagerAdapter)?.rebind() + binding?.viewpager?.adapter?.notifyDataSetChanged() super.onConfigurationChanged(newConfig) } + + private val sortChangeClickListener = View.OnClickListener { view -> + val methods = libraryViewModel.sortingMethods.map { + txt(it.stringRes).asString(view.context) + } + + activity?.showBottomDialog(methods, + libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), + txt(R.string.sort_by).asString(view.context), + false, + {}, + { + val method = libraryViewModel.sortingMethods[it] + libraryViewModel.sort(method) + }) + } } -class MenuSearchView(context: Context) : SearchView(context) { - override fun onActionViewCollapsed() { - super.onActionViewCollapsed() - } -} \ No newline at end of file +class MenuSearchView(context: Context) : SearchView(context) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt index 8aafbdd6..c3cee183 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt @@ -2,13 +2,13 @@ package com.lagradost.cloudstream3.ui.library import android.view.View import androidx.viewpager2.widget.ViewPager2 -import kotlinx.android.synthetic.main.library_viewpager_page.view.* +import com.lagradost.cloudstream3.R import kotlin.math.roundToInt class LibraryScrollTransformer : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) { val padding = (-position * page.width).roundToInt() - page.page_recyclerview.setPadding( + page.findViewById(R.id.page_recyclerview).setPadding( padding, 0, -padding, 0 ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt index 5f64880c..6c602e6c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt @@ -6,12 +6,14 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import kotlinx.coroutines.delay +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount enum class ListSorting(@StringRes val stringRes: Int) { Query(R.string.none), @@ -21,11 +23,20 @@ enum class ListSorting(@StringRes val stringRes: Int) { UpdatedOld(R.string.sort_updated_old), AlphabeticalA(R.string.sort_alphabetical_a), AlphabeticalZ(R.string.sort_alphabetical_z), + ReleaseDateNew(R.string.sort_release_date_new), + ReleaseDateOld(R.string.sort_release_date_old), } const val LAST_SYNC_API_KEY = "last_sync_api" class LibraryViewModel : ViewModel() { + fun switchPage(page : Int) { + _currentPage.postValue(page) + } + + private val _currentPage: MutableLiveData = MutableLiveData(0) + val currentPage: LiveData = _currentPage + private val _pages: MutableLiveData>> = MutableLiveData(null) val pages: LiveData>> = _pages @@ -36,12 +47,12 @@ class LibraryViewModel : ViewModel() { get() = SyncApis.filter { it.hasAccount() } var currentSyncApi = availableSyncApis.let { allApis -> - val lastSelection = getKey(LAST_SYNC_API_KEY) + val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull() } private set(value) { field = value - setKey(LAST_SYNC_API_KEY, field?.name) + setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name) } val availableApiNames: List @@ -59,13 +70,21 @@ class LibraryViewModel : ViewModel() { reloadPages(true) } - fun sort(method: ListSorting, query: String? = null) { - val currentList = pages.value ?: return + fun sort(method: ListSorting, query: String? = null) = ioSafe { + val value = _pages.value ?: return@ioSafe + if (value is Resource.Success) { + sort(method, query, value.value) + } + } + + private fun sort(method: ListSorting, query: String? = null, items: List) { currentSortingMethod = method - (currentList as? Resource.Success)?.value?.forEachIndexed { _, page -> + DataStoreHelper.librarySortingMode = method.ordinal + + items.forEach { page -> page.sort(method, query) } - _pages.postValue(currentList) + _pages.postValue(Resource.Success(items)) } fun reloadPages(forceReload: Boolean) { @@ -86,8 +105,6 @@ class LibraryViewModel : ViewModel() { val library = (libraryResource as? Resource.Success)?.value ?: return@let sortingMethods = library.supportedListSorting.toList() - currentSortingMethod = null - repo.requireLibraryRefresh = false val pages = library.allLibraryLists.map { @@ -97,8 +114,24 @@ class LibraryViewModel : ViewModel() { ) } - _pages.postValue(Resource.Success(pages)) + val desiredSortingMethod = + ListSorting.entries.getOrNull(DataStoreHelper.librarySortingMode) + if (desiredSortingMethod != null && library.supportedListSorting.contains(desiredSortingMethod)) { + sort(desiredSortingMethod, null, pages) + } else { + // null query = no sorting + sort(ListSorting.Query, null, pages) + } } } } -} \ No newline at end of file + + init { + MainActivity.reloadLibraryEvent += ::reloadPages + } + + override fun onCleared() { + MainActivity.reloadLibraryEvent -= ::reloadPages + super.onCleared() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt index a637133b..160fbe2b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt @@ -5,15 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.ListPopupWindow.MATCH_PARENT -import android.widget.RelativeLayout import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.loading_poster_dynamic.view.* -import kotlin.math.roundToInt -import kotlin.math.sqrt class LoadingPosterAdapter(context: Context, private val itemCount: Int) : BaseAdapter() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt index 2435f8be..b2de307f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt @@ -3,23 +3,21 @@ package com.lagradost.cloudstream3.ui.library import android.content.res.ColorStateList import android.graphics.Color import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.ImageView import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder -import com.lagradost.cloudstream3.utils.AppUtils +import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.search_result_grid_expanded.view.* import kotlin.math.roundToInt @@ -28,12 +26,15 @@ class PageAdapter( private val resView: AutofitRecyclerView, val clickCallback: (SearchClickCallback) -> Unit ) : - AppUtils.DiffAdapter(items) { + AppContextUtils.DiffAdapter(items) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return LibraryItemViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.search_result_grid_expanded, parent, false) + SearchResultGridExpandedBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } @@ -57,8 +58,8 @@ class PageAdapter( } } - inner class LibraryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val cardView: ImageView = itemView.imageView + inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) : + RecyclerView.ViewHolder(binding.root) { private val compactView = false//itemView.context.getGridIsCompact() private val coverHeight: Int = @@ -85,11 +86,12 @@ class PageAdapter( val fg = getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor)) - itemView.text_rating.apply { + binding.textRating.apply { setTextColor(ColorStateList.valueOf(fg)) } - itemView.text_rating_holder?.backgroundTintList = ColorStateList.valueOf(bg) - itemView.watchProgress?.apply { + binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg) + binding.textRating.backgroundTintList = ColorStateList.valueOf(bg) + binding.watchProgress.apply { progressTintList = ColorStateList.valueOf(fg) progressBackgroundTintList = ColorStateList.valueOf(bg) } @@ -99,7 +101,7 @@ class PageAdapter( // See searchAdaptor for this, it basically fixes the height if (!compactView) { - cardView.apply { + binding.imageView.apply { layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, coverHeight @@ -108,23 +110,13 @@ class PageAdapter( } val showProgress = item.episodesCompleted != null && item.episodesTotal != null - itemView.watchProgress.isVisible = showProgress + binding.watchProgress.isVisible = showProgress if (showProgress) { - itemView.watchProgress.max = item.episodesTotal!! - itemView.watchProgress.progress = item.episodesCompleted!! + binding.watchProgress.max = item.episodesTotal!! + binding.watchProgress.progress = item.episodesCompleted!! } - itemView.imageText.text = item.name - - val showRating = (item.personalRating ?: 0) != 0 - itemView.text_rating_holder.isVisible = showRating - if (showRating) { - // We want to show 8.5 but not 8.0 hence the replace - val rating = ((item.personalRating ?: 0).toDouble() / 10).toString() - .replace(".0", "") - - itemView.text_rating.text = "★ $rating" - } + binding.imageText.text = item.name } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index 33a40386..0110187f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt @@ -1,90 +1,124 @@ package com.lagradost.cloudstream3.ui.library import android.os.Build +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.view.doOnAttach -import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView.OnFlingListener +import com.google.android.material.appbar.AppBarLayout import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.LibraryViewpagerPageBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.home.getSafeParcelable import com.lagradost.cloudstream3.ui.search.SearchClickCallback +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import kotlinx.android.synthetic.main.library_viewpager_page.view.* + +class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) : + ViewHolderState(binding) { + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "pageRecyclerview", + binding.pageRecyclerview.layoutManager?.onSaveInstanceState() + ) + } + + override fun restore(state: Bundle) { + state.getSafeParcelable("pageRecyclerview")?.let { recycle -> + binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle) + } + } +} class ViewpagerAdapter( - var pages: List, + fragment: Fragment, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return PageViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.library_viewpager_page, parent, false) +) : BaseAdapter(fragment, + id = "ViewpagerAdapter".hashCode(), + diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.title == b.title + }, + contentSame = { a, b -> + a.items == b.items && a.title == b.title + } +)) { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewpagerAdapterViewHolderState( + LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PageViewHolder -> { - holder.bind(pages[position], unbound.remove(position)) - } - } + override fun onUpdateContent( + holder: ViewHolderState, + item: SyncAPI.Page, + position: Int + ) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return + (binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items) } - private val unbound = mutableSetOf() - /** - * Used to mark all pages for re-binding and forces all items to be refreshed - * Without this the pages will still use the same adapters - **/ - fun rebind() { - unbound.addAll(0..pages.size) - this.notifyItemRangeChanged(0, pages.size) - } + override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return - inner class PageViewHolder(private val itemViewTest: View) : - RecyclerView.ViewHolder(itemViewTest) { - fun bind(page: SyncAPI.Page, rebind: Boolean) { - itemView.page_recyclerview?.spanCount = - this@PageViewHolder.itemView.context.getSpanCount() ?: 3 - - if (itemViewTest.page_recyclerview?.adapter == null || rebind) { + binding.pageRecyclerview.tag = position + binding.pageRecyclerview.apply { + spanCount = + binding.root.context.getSpanCount() ?: 3 + if (adapter == null) { // || rebind // Only add the items after it has been attached since the items rely on ItemWidth // Which is only determined after the recyclerview is attached. // If this fails then item height becomes 0 when there is only one item - itemViewTest.page_recyclerview?.doOnAttach { - itemViewTest.page_recyclerview?.adapter = PageAdapter( - page.items.toMutableList(), - itemViewTest.page_recyclerview, + doOnAttach { + adapter = PageAdapter( + item.items.toMutableList(), + this, clickCallback ) } } else { - (itemViewTest.page_recyclerview?.adapter as? PageAdapter)?.updateList(page.items) - itemViewTest.page_recyclerview?.scrollToPosition(0) + (adapter as? PageAdapter)?.updateList(item.items) + // scrollToPosition(0) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - itemViewTest.page_recyclerview.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY -> + setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val diff = scrollY - oldScrollY + + //Expand the top Appbar based on scroll direction up/down, simulate phone behavior + if (isLayout(TV or EMULATOR)) { + binding.root.rootView.findViewById(R.id.search_bar) + .apply { + if (diff <= 0) + setExpanded(true) + else + setExpanded(false) + } + } if (diff == 0) return@setOnScrollChangeListener scrollCallback.invoke(diff > 0) } } else { - itemViewTest.page_recyclerview.onFlingListener = object : OnFlingListener() { + onFlingListener = object : OnFlingListener() { override fun onFling(velocityX: Int, velocityY: Int): Boolean { scrollCallback.invoke(velocityY > 0) return false } } } - } } - - override fun getItemCount(): Int { - return pages.size - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 21047db3..ee987f44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,49 +1,60 @@ package com.lagradost.cloudstream3.ui.player import android.annotation.SuppressLint -import android.content.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.graphics.drawable.AnimatedImageDrawable import android.graphics.drawable.AnimatedVectorDrawable import android.media.metrics.PlaybackErrorEvent import android.os.Build import android.os.Bundle -import android.support.v4.media.session.MediaSessionCompat +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar import android.widget.Toast import androidx.annotation.LayoutRes import androidx.annotation.StringRes +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.media.session.MediaButtonReceiver +import androidx.media3.common.PlaybackException +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar import androidx.preference.PreferenceManager import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.PlaybackException -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout -import com.google.android.exoplayer2.ui.SubtitleView -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode import com.lagradost.cloudstream3.CommonActivity.isInPIPMode import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener +import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.unixTimeMs import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.AppUtils -import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.fragment_player.* -import kotlinx.android.synthetic.main.player_custom_layout.* enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -72,9 +83,15 @@ abstract class AbstractPlayerFragment( var isBuffering = true protected open var hasPipModeSupport = true + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + var playerView: PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null @LayoutRes - protected var layout: Int = R.layout.fragment_player + protected open var layout: Int = R.layout.fragment_player open fun nextEpisode() { throw NotImplementedError() @@ -84,11 +101,13 @@ abstract class AbstractPlayerFragment( throw NotImplementedError() } - open fun playerPositionChanged(posDur: Pair) { + open fun playerPositionChanged(position: Long, duration: Long) { throw NotImplementedError() } - open fun playerDimensionsLoaded(widthHeight: Pair) { + open fun playerStatusChanged(){} + + open fun playerDimensionsLoaded(width: Int, height: Int) { throw NotImplementedError() } @@ -124,8 +143,10 @@ abstract class AbstractPlayerFragment( } } - private fun updateIsPlaying(playing: Pair) { - val (wasPlaying, isPlaying) = playing + private fun updateIsPlaying( + wasPlaying: CSPlayerLoading, + isPlaying: CSPlayerLoading + ) { val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying @@ -133,15 +154,15 @@ abstract class AbstractPlayerFragment( isBuffering = CSPlayerLoading.IsBuffering == isPlaying if (isBuffering) { - player_pause_play_holder_holder?.isVisible = false - player_buffering?.isVisible = true + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true } else { - player_pause_play_holder_holder?.isVisible = true - player_buffering?.isVisible = false + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false if (wasPlaying != isPlaying) { - player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) - val drawable = player_pause_play?.drawable + playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) + val drawable = playerPausePlay?.drawable var startedAnimation = false if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { @@ -163,28 +184,33 @@ abstract class AbstractPlayerFragment( // somehow the phone is wacked if (!startedAnimation) { - player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) + playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) } } else { - player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) + playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) } } canEnterPipMode = isPlayingRightNow && hasPipModeSupport - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPIPMode) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { activity?.let { act -> - PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow) + PlayerPipHelper.updatePIPModeActions( + act, + isPlayingRightNow, + player.getAspectRatio() + ) } } } private var pipReceiver: BroadcastReceiver? = null override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) try { isInPIPMode = isInPictureInPictureMode if (isInPictureInPictureMode) { // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. - player_holder?.alpha = 0f + piphide?.isVisible = false pipReceiver = object : BroadcastReceiver() { override fun onReceive( context: Context, @@ -194,28 +220,29 @@ abstract class AbstractPlayerFragment( return } player.handleEvent( - CSPlayerEvent.values()[intent.getIntExtra( + CSPlayerEvent.entries[intent.getIntExtra( EXTRA_CONTROL_TYPE, 0 - )] + )], source = PlayerEventSource.UI ) } } val filter = IntentFilter() - filter.addAction( - ACTION_MEDIA_CONTROL - ) + filter.addAction(ACTION_MEDIA_CONTROL) activity?.registerReceiver(pipReceiver, filter) val isPlaying = player.getIsPlaying() val isPlayingValue = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(Pair(isPlayingValue, isPlayingValue)) + updateIsPlaying(isPlayingValue, isPlayingValue) } else { // Restore the full-screen UI. - player_holder?.alpha = 1f + piphide?.isVisible = true exitedPipMode() pipReceiver?.let { - activity?.unregisterReceiver(it) + // Prevents java.lang.IllegalArgumentException: Receiver not registered + normalSafeApiCall { + activity?.unregisterReceiver(it) + } } activity?.hideSystemUI() this.view?.let { UIHelper.hideKeyboard(it) } @@ -235,22 +262,20 @@ abstract class AbstractPlayerFragment( private fun requestAudioFocus() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppUtils.getFocusRequest()) + activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) } } - open fun playerError(exception: Exception) { + open fun playerError(exception: Throwable) { fun showToast(message: String, gotoNext: Boolean = false) { if (gotoNext && hasNextMirror()) { showToast( - activity, message, Toast.LENGTH_SHORT ) nextMirror() } else { showToast( - activity, context?.getString(R.string.no_links_found_toast) + "\n" + message, Toast.LENGTH_LONG ) @@ -270,18 +295,21 @@ abstract class AbstractPlayerFragment( gotoNext = true ) } + PlaybackException.ERROR_CODE_REMOTE_ERROR, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_TIMEOUT, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { showToast( "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", gotoNext = true ) } + PlaybackException.ERROR_CODE_DECODING_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> { showToast( "${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg", gotoNext = true ) } + else -> { showToast( "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", @@ -290,12 +318,14 @@ abstract class AbstractPlayerFragment( } } } + is InvalidFileException -> { showToast( "${ctx.getString(R.string.source_error)}\n${exception.message}", gotoNext = true ) } + else -> { exception.message?.let { showToast( @@ -313,29 +343,25 @@ abstract class AbstractPlayerFragment( } } + @SuppressLint("UnsafeOptInUsageError") private fun playerUpdated(player: Any?) { if (player is ExoPlayer) { context?.let { ctx -> - val mediaButtonReceiver = ComponentName(ctx, MediaButtonReceiver::class.java) - MediaSessionCompat(ctx, "Player", mediaButtonReceiver, null).let { media -> - //media.setCallback(mMediaSessionCallback) - //media.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS) - val mediaSessionConnector = MediaSessionConnector(media) - mediaSessionConnector.setPlayer(player) - media.isActive = true - mMediaSessionCompat = media - } + mMediaSession?.release() + mMediaSession = MediaSession.Builder(ctx, player) + // Ensure unique ID for concurrent players + .setId(unixTimeMs.toString()) + .build() } // Necessary for multiple combined videos - player_view?.setShowMultiWindowTimeBar(true) - player_view?.player = player - player_view?.performClick() + playerView?.setShowMultiWindowTimeBar(true) + playerView?.player = player + playerView?.performClick() } } - private var mediaSessionConnector: MediaSessionConnector? = null - private var mMediaSessionCompat: MediaSessionCompat? = null + private var mMediaSession: MediaSession? = null // this can be used in the future for players other than exoplayer //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { @@ -358,39 +384,174 @@ abstract class AbstractPlayerFragment( // } //} + /** This receives the events from the player, if you want to append functionality you do it here, + * do note that this only receives events for UI changes, + * and returning early WONT stop it from changing in eg the player time or pause status */ + open fun mainCallback(event: PlayerEvent) { + Log.i(TAG, "Handle event: $event") + when (event) { + is ResizedEvent -> { + playerDimensionsLoaded(event.width, event.height) + } - @SuppressLint("SetTextI18n") + is PlayerAttachedEvent -> { + playerUpdated(event.player) + } + + is SubtitlesUpdatedEvent -> { + subtitlesChanged() + } + + is TimestampSkippedEvent -> { + onTimestampSkipped(event.timestamp) + } + + is TimestampInvokedEvent -> { + onTimestamp(event.timestamp) + } + + is TracksChangedEvent -> { + onTracksInfoChanged() + } + + is EmbeddedSubtitlesFetchedEvent -> { + embeddedSubtitlesFetched(event.tracks) + } + + is ErrorEvent -> { + playerError(event.error) + } + + is RequestAudioFocusEvent -> { + requestAudioFocus() + } + + is EpisodeSeekEvent -> { + when (event.offset) { + -1 -> prevEpisode() + 1 -> nextEpisode() + else -> {} + } + } + + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + playerStatusChanged() + } + + is PositionEvent -> { + playerPositionChanged(position = event.toMs, duration = event.durationMs) + } + + is VideoEndedEvent -> { + context?.let { ctx -> + // Resets subtitle delay on ended video + player.setSubtitleOffset(0) + + // Only play next episode if autoplay is on (default) + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean( + ctx.getString(R.string.autoplay_next_key), + true + ) == true + ) { + player.handleEvent( + CSPlayerEvent.NextEpisode, + source = PlayerEventSource.Player + ) + } + } + } + + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - resizeMode = getKey(RESIZE_MODE_KEY) ?: 0 + resizeMode = DataStoreHelper.resizeMode resize(resizeMode, false) player.releaseCallbacks() player.initCallbacks( - playerUpdated = ::playerUpdated, - updateIsPlaying = ::updateIsPlaying, - playerError = ::playerError, - requestAutoFocus = ::requestAudioFocus, - nextEpisode = ::nextEpisode, - prevEpisode = ::prevEpisode, - playerPositionChanged = ::playerPositionChanged, - playerDimensionsLoaded = ::playerDimensionsLoaded, + eventHandler = ::mainCallback, requestedListeningPercentages = listOf( SKIP_OP_VIDEO_PERCENTAGE, PRELOAD_NEXT_EPISODE_PERCENTAGE, NEXT_WATCH_EPISODE_PERCENTAGE, UPDATE_SYNC_PROGRESS_PERCENTAGE, ), - subtitlesUpdates = ::subtitlesChanged, - embeddedSubtitlesFetched = ::embeddedSubtitlesFetched, - onTracksInfoChanged = ::onTracksInfoChanged, - onTimestampInvoked = ::onTimestamp, - onTimestampSkipped = ::onTimestampSkipped ) if (player is CS3IPlayer) { - subView = player_view?.findViewById(R.id.exo_subtitles) + // preview bar + val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress) + val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout) + if (progressBar != null && previewImageView != null && previewFrameLayout != null) { + var resume = false + progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { + override fun onScrubStart(previewBar: PreviewBar?) { + val hasPreview = player.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = player.getIsPlaying() + if (resume) player.handleEvent( + CSPlayerEvent.Pause, + PlayerEventSource.Player + ) + } + + override fun onScrubMove( + previewBar: PreviewBar?, + progress: Int, + fromUser: Boolean + ) { + } + + override fun onScrubStop(previewBar: PreviewBar?) { + if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = playerView?.findViewById(R.id.exo_subtitles) subStyle = SubtitlesFragment.getCurrentSavedStyle() - player.initSubtitles(subView, subtitle_holder, subStyle) + player.initSubtitles(subView, subtitleHolder, subStyle) + (player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth) + + /*previewImageView?.doOnLayout { + (player.imageGenerator as? PreviewGenerator)?.params = ImageParams( + it.measuredWidth, + it.measuredHeight + ) + }*/ + /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI */ + playerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged @@ -436,6 +597,9 @@ abstract class AbstractPlayerFragment( playerEventListener = null keyEventListener = null canEnterPipMode = false + mMediaSession?.release() + mMediaSession = null + playerView?.player = null SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged keepScreenOn(false) @@ -443,25 +607,26 @@ abstract class AbstractPlayerFragment( } fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.values().size + resizeMode = (resizeMode + 1) % PlayerResize.entries.size resize(resizeMode, true) } fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.values()[resize], showToast) + resize(PlayerResize.entries[resize], showToast) } + @SuppressLint("UnsafeOptInUsageError") fun resize(resize: PlayerResize, showToast: Boolean) { - setKey(RESIZE_MODE_KEY, resize.ordinal) + DataStoreHelper.resizeMode = resize.ordinal val type = when (resize) { PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM } - player_view?.resizeMode = type + playerView?.resizeMode = type if (showToast) - showToast(activity, resize.nameRes, Toast.LENGTH_SHORT) + showToast(resize.nameRes, Toast.LENGTH_SHORT) } override fun onStop() { @@ -482,6 +647,13 @@ abstract class AbstractPlayerFragment( container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(layout, container, false) + val root = inflater.inflate(layout, container, false) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerBuffering = root.findViewById(R.id.player_buffering) + playerView = root.findViewById(R.id.player_view) + piphide = root.findViewById(R.id.piphide) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + return root } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 4772a7f1..86d67b28 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1,45 +1,77 @@ package com.lagradost.cloudstream3.ui.player +import android.annotation.SuppressLint import android.content.Context +import android.graphics.Bitmap import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log +import android.util.Rational import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.media3.common.C.TIME_UNSET +import androidx.media3.common.C.TRACK_TYPE_AUDIO +import androidx.media3.common.C.TRACK_TYPE_TEXT +import androidx.media3.common.C.TRACK_TYPE_VIDEO +import androidx.media3.common.Format +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.HttpDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager +import androidx.media3.exoplayer.drm.FrameworkMediaDrm +import androidx.media3.exoplayer.drm.LocalMediaDrmCallback +import androidx.media3.exoplayer.source.ClippingMediaSource +import androidx.media3.exoplayer.source.ConcatenatingMediaSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.SingleSampleMediaSource +import androidx.media3.exoplayer.text.TextRenderer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.trackselection.TrackSelector +import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager -import com.google.android.exoplayer2.* -import com.google.android.exoplayer2.C.* -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider -import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource -import com.google.android.exoplayer2.source.* -import com.google.android.exoplayer2.text.TextRenderer -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector -import com.google.android.exoplayer2.trackselection.TrackSelectionOverride -import com.google.android.exoplayer2.trackselection.TrackSelector -import com.google.android.exoplayer2.ui.SubtitleView -import com.google.android.exoplayer2.upstream.DataSource -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource -import com.google.android.exoplayer2.upstream.HttpDataSource -import com.google.android.exoplayer2.upstream.cache.CacheDataSource -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor -import com.google.android.exoplayer2.upstream.cache.SimpleCache -import com.google.android.exoplayer2.util.MimeTypes -import com.google.android.exoplayer2.video.VideoSize import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import java.io.File +import java.util.UUID import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession @@ -47,15 +79,33 @@ import javax.net.ssl.SSLSession const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" -/** Cache */ +/** toleranceBeforeUs – The maximum time that the actual position seeked to may precede the + * requested seek position, in microseconds. Must be non-negative. */ +const val toleranceBeforeUs = 300_000L +/** + * toleranceAfterUs – The maximum time that the actual position seeked to may exceed the requested + * seek position, in microseconds. Must be non-negative. + */ +const val toleranceAfterUs = 300_000L +@OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { private var isPlaying = false private var exoPlayer: ExoPlayer? = null + set(value) { + // If the old value is not null then the player has not been properly released. + debugAssert( + { field != null && value != null }, + { "Previous player instance should be released!" }) + field = value + } + var cacheSize = 0L var simpleCacheSize = 0L var videoBufferMs = 0L + val imageGenerator = IPreviewGenerator.new() + private val seekActionTime = 30000L private var ignoreSSL: Boolean = true @@ -78,7 +128,16 @@ class CS3IPlayer : IPlayer { * */ data class MediaItemSlice( val mediaItem: MediaItem, - val durationUs: Long + val durationUs: Long, + val drm: DrmMetadata? = null + ) + + data class DrmMetadata( + val kid: String, + val key: String, + val uuid: UUID, + val kty: String, + val keyRequestParameters: HashMap, ) override fun getDuration(): Long? = exoPlayer?.duration @@ -92,80 +151,24 @@ class CS3IPlayer : IPlayer { * Boolean = if it's active * */ private var playerSelectedSubtitleTracks = listOf>() - - /** isPlaying */ - private var updateIsPlaying: ((Pair) -> Unit)? = null - private var requestAutoFocus: (() -> Unit)? = null - private var playerError: ((Exception) -> Unit)? = null - private var subtitlesUpdates: (() -> Unit)? = null - - /** width x height */ - private var playerDimensionsLoaded: ((Pair) -> Unit)? = null - - /** used for playerPositionChanged */ private var requestedListeningPercentages: List? = null - /** Fired when seeking the player or on requestedListeningPercentages, - * used to make things appear on que - * position, duration */ - private var playerPositionChanged: ((Pair) -> Unit)? = null + private var eventHandler: ((PlayerEvent) -> Unit)? = null - private var nextEpisode: (() -> Unit)? = null - private var prevEpisode: (() -> Unit)? = null - - private var playerUpdated: ((Any?) -> Unit)? = null - private var embeddedSubtitlesFetched: ((List) -> Unit)? = null - private var onTracksInfoChanged: (() -> Unit)? = null - private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null - private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null + fun event(event: PlayerEvent) { + eventHandler?.invoke(event) + } override fun releaseCallbacks() { - playerUpdated = null - updateIsPlaying = null - requestAutoFocus = null - playerError = null - playerDimensionsLoaded = null - requestedListeningPercentages = null - playerPositionChanged = null - nextEpisode = null - prevEpisode = null - subtitlesUpdates = null - onTracksInfoChanged = null - onTimestampInvoked = null - requestSubtitleUpdate = null - onTimestampSkipped = null + eventHandler = null } override fun initCallbacks( - playerUpdated: (Any?) -> Unit, - updateIsPlaying: ((Pair) -> Unit)?, - requestAutoFocus: (() -> Unit)?, - playerError: ((Exception) -> Unit)?, - playerDimensionsLoaded: ((Pair) -> Unit)?, + eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, - playerPositionChanged: ((Pair) -> Unit)?, - nextEpisode: (() -> Unit)?, - prevEpisode: (() -> Unit)?, - subtitlesUpdates: (() -> Unit)?, - embeddedSubtitlesFetched: ((List) -> Unit)?, - onTracksInfoChanged: (() -> Unit)?, - onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?, - onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?, ) { - this.playerUpdated = playerUpdated - this.updateIsPlaying = updateIsPlaying - this.requestAutoFocus = requestAutoFocus - this.playerError = playerError - this.playerDimensionsLoaded = playerDimensionsLoaded this.requestedListeningPercentages = requestedListeningPercentages - this.playerPositionChanged = playerPositionChanged - this.nextEpisode = nextEpisode - this.prevEpisode = prevEpisode - this.subtitlesUpdates = subtitlesUpdates - this.embeddedSubtitlesFetched = embeddedSubtitlesFetched - this.onTracksInfoChanged = onTracksInfoChanged - this.onTimestampInvoked = onTimestampInvoked - this.onTimestampSkipped = onTimestampSkipped + this.eventHandler = eventHandler } // I know, this is not a perfect solution, however it works for fixing subs @@ -174,7 +177,7 @@ class CS3IPlayer : IPlayer { try { Handler(it).post { try { - seekTime(1L) + seekTime(1L, source = PlayerEventSource.Player) } catch (e: Exception) { logError(e) } @@ -189,6 +192,14 @@ class CS3IPlayer : IPlayer { subtitleHelper.initSubtitles(subView, subHolder, style) } + override fun getPreview(fraction: Float): Bitmap? { + return imageGenerator.getPreviewImage(fraction) + } + + override fun hasPreview(): Boolean { + return imageGenerator.hasPreview() + } + override fun loadPlayer( context: Context, sameEpisode: Boolean, @@ -197,7 +208,8 @@ class CS3IPlayer : IPlayer { startPosition: Long?, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean? + autoPlay: Boolean?, + preview: Boolean, ) { Log.i(TAG, "loadPlayer") if (sameEpisode) { @@ -216,11 +228,30 @@ class CS3IPlayer : IPlayer { // release the current exoplayer and cache releasePlayer() + if (link != null) { + // only video support atm + (imageGenerator as? PreviewGenerator)?.let { gen -> + if (preview) { + gen.load(link, sameEpisode) + } else { + gen.clear(sameEpisode) + } + } loadOnlinePlayer(context, link) } else if (data != null) { + (imageGenerator as? PreviewGenerator)?.let { gen -> + if (preview) { + gen.load(context, data, sameEpisode) + } else { + gen.clear(sameEpisode) + } + } loadOfflinePlayer(context, data) + } else { + throw IllegalArgumentException("Requires link or uri") } + } override fun setActiveSubtitles(subtitles: Set) { @@ -228,7 +259,7 @@ class CS3IPlayer : IPlayer { subtitleHelper.setAllSubtitles(subtitles) } - var currentSubtitles: SubtitleData? = null + private var currentSubtitles: SubtitleData? = null private fun List.getTrack(id: String?): Pair? { if (id == null) return null @@ -373,7 +404,7 @@ class CS3IPlayer : IPlayer { if (subtitle == null) { trackSelector.setParameters( trackSelector.buildUponParameters() - .setPreferredTextLanguage(null) + .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) .clearOverridesOfType(TRACK_TYPE_TEXT) ) } else { @@ -382,6 +413,7 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") return@let true } + SubtitleStatus.IS_ACTIVE -> { Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") @@ -390,6 +422,7 @@ class CS3IPlayer : IPlayer { .apply { val track = getTextTrack(subtitle.getId()) if (track != null) { + setTrackTypeDisabled(TRACK_TYPE_TEXT, false) setOverrideForType( TrackSelectionOverride( track.first, @@ -407,6 +440,7 @@ class CS3IPlayer : IPlayer { // }, 1) //} } + SubtitleStatus.NOT_FOUND -> { Log.i(TAG, "setPreferredSubtitles NOT_FOUND") return@let true @@ -417,7 +451,7 @@ class CS3IPlayer : IPlayer { } ?: false } - var currentSubtitleOffset: Long = 0 + private var currentSubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset @@ -425,7 +459,7 @@ class CS3IPlayer : IPlayer { } override fun getSubtitleOffset(): Long { - return currentSubtitleOffset //currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset + return currentSubtitleOffset } override fun getCurrentPreferredSubtitle(): SubtitleData? { @@ -436,6 +470,12 @@ class CS3IPlayer : IPlayer { } } + override fun getAspectRatio(): Rational? { + return exoPlayer?.videoFormat?.let { format -> + Rational(format.width, format.height) + } + } + override fun updateSubtitleStyle(style: SaveCaptionStyle) { subtitleHelper.setSubStyle(style) } @@ -446,7 +486,7 @@ class CS3IPlayer : IPlayer { exoPlayer?.let { exo -> playbackPosition = exo.currentPosition - currentWindow = exo.currentWindowIndex + currentWindow = exo.currentMediaItemIndex isPlaying = exo.isPlaying } } @@ -457,7 +497,11 @@ class CS3IPlayer : IPlayer { if (saveTime) updatedTime() - exoPlayer?.release() + exoPlayer?.apply { + playWhenReady = false + stop() + release() + } //simpleCache?.release() currentTextRenderer = null @@ -469,14 +513,14 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "onStop") saveData() - exoPlayer?.pause() + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) //releasePlayer() } override fun onPause() { Log.i(TAG, "onPause") saveData() - exoPlayer?.pause() + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) //releasePlayer() } @@ -486,6 +530,7 @@ class CS3IPlayer : IPlayer { } override fun release() { + imageGenerator.release() releasePlayer() } @@ -500,12 +545,15 @@ class CS3IPlayer : IPlayer { **/ var preferredAudioTrackLanguage: String? = null get() { - return field ?: getKey(PREFERRED_AUDIO_LANGUAGE_KEY, field)?.also { + return field ?: getKey( + "$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", + field + )?.also { field = it } } set(value) { - setKey(PREFERRED_AUDIO_LANGUAGE_KEY, value) + setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value) field = value } @@ -535,15 +583,17 @@ class CS3IPlayer : IPlayer { OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) } + // Do no include empty referer, if the provider wants those they can use the header map. + val refererMap = + if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) val headers = mapOf( - "referer" to link.referer, "accept" to "*/*", "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", "sec-ch-ua-mobile" to "?0", "sec-fetch-user" to "?1", "sec-fetch-mode" to "navigate", "sec-fetch-dest" to "video" - ) + link.headers // Adds the headers from the provider, e.g Authorization + ) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -551,57 +601,16 @@ class CS3IPlayer : IPlayer { } private fun Context.createOfflineSource(): DataSource.Factory { - return DefaultDataSourceFactory(this, USER_AGENT) + return DefaultDataSource.Factory(this, DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT)) } - /*private fun getSubSources( - onlineSourceFactory: DataSource.Factory?, - offlineSourceFactory: DataSource.Factory?, - subHelper: PlayerSubtitleHelper, - ): Pair, List> { - val activeSubtitles = ArrayList() - val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url)) - .setMimeType(sub.mimeType) - .setLanguage("_${sub.name}") - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .build() - when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { - if (offlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(offlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.URL -> { - if (onlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(onlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.OPEN_SUBTITLES -> { - // TODO - throw NotImplementedError() - } - } - } - println("SUBSRC: ${subSources.size} activeSubtitles : ${activeSubtitles.size} of ${subHelper.getAllSubtitles().size} ") - return Pair(subSources, activeSubtitles) - }*/ - private fun getCache(context: Context, cacheSize: Long): SimpleCache? { return try { val databaseProvider = StandaloneDatabaseProvider(context) SimpleCache( File( context.cacheDir, "exoplayer" - ).also { it.deleteOnExit() }, // Ensures always fresh file + ).also { deleteFileOnExit(it) }, // Ensures always fresh file LeastRecentlyUsedCacheEvictor(cacheSize), databaseProvider ) @@ -628,12 +637,7 @@ class CS3IPlayer : IPlayer { private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { val trackSelector = DefaultTrackSelector(context) - trackSelector.parameters = DefaultTrackSelector.ParametersBuilder(context) - // .setRendererDisabled(C.TRACK_TYPE_VIDEO, true) - .setRendererDisabled(C.TRACK_TYPE_TEXT, true) - // Experimental, I think this causes issues with audio track init 5001 -// .setTunnelingEnabled(true) - .setDisabledTextTrackSelectionFlags(C.TRACK_TYPE_TEXT) + trackSelector.parameters = trackSelector.buildUponParameters() // This will not force higher quality videos to fail // but will make the m3u8 pick the correct preferred .setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE) @@ -666,7 +670,11 @@ class CS3IPlayer : IPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> - DefaultRenderersFactory(context).createRenderers( + DefaultRenderersFactory(context).apply { + setEnableDecoderFallback(true) + // Enable Ffmpeg extension + setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) + }.createRenderers( eventHandler, videoRendererEventListener, audioRendererEventListener, @@ -674,13 +682,13 @@ class CS3IPlayer : IPlayer { metadataRendererOutput ).map { if (it is TextRenderer) { - currentTextRenderer = CustomTextRenderer( + val currentTextRenderer = CustomTextRenderer( subtitleOffset, textRendererOutput, eventHandler.looper, CustomSubtitleDecoderFactory() - ) - currentTextRenderer!! + ).also { renderer -> this.currentTextRenderer = renderer } + currentTextRenderer } else it }.toTypedArray() } @@ -691,7 +699,7 @@ class CS3IPlayer : IPlayer { ) ) // Allows any seeking to be +- 0.3s to allow for faster seeking - .setSeekParameters(SeekParameters(300_000, 300_000)) + .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs)) .setLoadControl( DefaultLoadControl.Builder() .setTargetBufferBytes( @@ -701,6 +709,10 @@ class CS3IPlayer : IPlayer { if (cacheSize > Int.MAX_VALUE) Int.MAX_VALUE else cacheSize.toInt() } ) + .setBackBuffer( + 30000, + true + ) .setBufferDurationsMs( DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, if (videoBufferMs <= 0) { @@ -720,15 +732,33 @@ class CS3IPlayer : IPlayer { // If there is only one item then treat it as normal, if multiple: concatenate the items. val videoMediaSource = if (mediaItemSlices.size == 1) { - factory.createMediaSource(mediaItemSlices.first().mediaItem) + val item = mediaItemSlices.first() + + item.drm?.let { drm -> + val drmCallback = + LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) + val manager = DefaultDrmSessionManager.Builder() + .setPlayClearSamplesWithoutKeys(true) + .setMultiSession(false) + .setKeyRequestParameters(drm.keyRequestParameters) + .setUuidAndExoMediaDrmProvider(drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER) + .build(drmCallback) + val manifestDataSourceFactory = DefaultHttpDataSource.Factory() + + DashMediaSource.Factory(manifestDataSourceFactory) + .setDrmSessionManagerProvider { manager } + .createMediaSource(item.mediaItem) + } ?: run { + factory.createMediaSource(item.mediaItem) + } } else { val source = ConcatenatingMediaSource() - mediaItemSlices.map { + mediaItemSlices.map { item -> source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( - factory.createMediaSource(it.mediaItem), - it.durationUs + factory.createMediaSource(item.mediaItem), + item.durationUs ) ) } @@ -754,50 +784,65 @@ class CS3IPlayer : IPlayer { private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { - if (lastTimeStamp.startMs <= position && position < lastTimeStamp.endMs) { + if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { return lastTimeStamp } } return null } - fun updatedTime(writePosition: Long? = null) { - getCurrentTimestamp(writePosition)?.let { timestamp -> - onTimestampInvoked?.invoke(timestamp) + fun updatedTime( + writePosition: Long? = null, + source: PlayerEventSource = PlayerEventSource.Player + ) { + val position = writePosition ?: exoPlayer?.currentPosition + + getCurrentTimestamp(position)?.let { timestamp -> + event(TimestampInvokedEvent(timestamp, source)) } - val position = writePosition ?: exoPlayer?.currentPosition val duration = exoPlayer?.contentDuration if (duration != null && position != null) { - playerPositionChanged?.invoke(Pair(position, duration)) + event( + PositionEvent( + source, + fromMs = exoPlayer?.currentPosition ?: 0, + position, + duration + ) + ) } } - override fun seekTime(time: Long) { - exoPlayer?.seekTime(time) + override fun seekTime(time: Long, source: PlayerEventSource) { + exoPlayer?.seekTime(time, source) } - override fun seekTo(time: Long) { - updatedTime(time) + override fun seekTo(time: Long, source: PlayerEventSource) { + updatedTime(time, source) exoPlayer?.seekTo(time) } - private fun ExoPlayer.seekTime(time: Long) { - updatedTime(currentPosition + time) + private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) { + updatedTime(currentPosition + time, source) seekTo(currentPosition + time) } - override fun handleEvent(event: CSPlayerEvent) { + override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) { Log.i(TAG, "handleEvent ${event.name}") try { exoPlayer?.apply { when (event) { CSPlayerEvent.Play -> { + event(PlayEvent(source)) play() } + CSPlayerEvent.Pause -> { + event(PauseEvent(source)) pause() } + CSPlayerEvent.ToggleMute -> { if (volume <= 0) { //is muted @@ -808,33 +853,51 @@ class CS3IPlayer : IPlayer { volume = 0f } } + CSPlayerEvent.PlayPauseToggle -> { if (isPlaying) { - pause() + handleEvent(CSPlayerEvent.Pause, source) } else { - play() + handleEvent(CSPlayerEvent.Play, source) } } - CSPlayerEvent.SeekForward -> seekTime(seekActionTime) - CSPlayerEvent.SeekBack -> seekTime(-seekActionTime) - CSPlayerEvent.NextEpisode -> nextEpisode?.invoke() - CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke() + + CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source) + + CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source) + + CSPlayerEvent.Restart -> seekTo(0, source) + + CSPlayerEvent.NextEpisode -> event( + EpisodeSeekEvent( + offset = 1, + source = source + ) + ) + + CSPlayerEvent.PrevEpisode -> event( + EpisodeSeekEvent( + offset = -1, + source = source + ) + ) + CSPlayerEvent.SkipCurrentChapter -> { //val dur = this@CS3IPlayer.getDuration() ?: return@apply getCurrentTimestamp()?.let { lastTimeStamp -> if (lastTimeStamp.skipToNextEpisode) { - handleEvent(CSPlayerEvent.NextEpisode) + handleEvent(CSPlayerEvent.NextEpisode, source) } else { seekTo(lastTimeStamp.endMs + 1L) } - onTimestampSkipped?.invoke(lastTimeStamp) + event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } } } } - } catch (e: Exception) { - Log.e(TAG, "handleEvent error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "handleEvent error", t) + event(ErrorEvent(t)) } } @@ -847,7 +910,7 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val maxVideoHeight = settingsManager.getInt( - context.getString(com.lagradost.cloudstream3.R.string.quality_pref_key), + context.getString(if (context.isUsingMobileData()) com.lagradost.cloudstream3.R.string.quality_pref_mobile_data_key else com.lagradost.cloudstream3.R.string.quality_pref_key), Int.MAX_VALUE ) @@ -873,18 +936,14 @@ class CS3IPlayer : IPlayer { requestSubtitleUpdate = ::reloadSubs - playerUpdated?.invoke(exoPlayer) + event(PlayerAttachedEvent(exoPlayer)) exoPlayer?.prepare() exoPlayer?.let { exo -> - updateIsPlaying?.invoke( - Pair( - CSPlayerLoading.IsBuffering, - CSPlayerLoading.IsBuffering - ) - ) + event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying } + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { normalSafeApiCall { @@ -914,22 +973,24 @@ class CS3IPlayer : IPlayer { format.id!!, SubtitleOrigin.EMBEDDED_IN_VIDEO, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, - emptyMap() + emptyMap(), + format.language ) } - embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks) - onTracksInfoChanged?.invoke() - subtitlesUpdates?.invoke() + event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) + event(TracksChangedEvent()) + event(SubtitlesUpdatedEvent()) } } + //fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> - updateIsPlaying?.invoke( - Pair( - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, - if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + event( + StatusEvent( + wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, + isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused ) ) isPlaying = exo.isPlaying @@ -939,6 +1000,7 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { onRenderFirst() } + else -> {} } @@ -948,23 +1010,19 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { } + Player.STATE_ENDED -> { - // Only play next episode if autoplay is on (default) - if (PreferenceManager.getDefaultSharedPreferences(context) - ?.getBoolean( - context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key), - true - ) == true - ) { - handleEvent(CSPlayerEvent.NextEpisode) - } + event(VideoEndedEvent()) } + Player.STATE_BUFFERING -> { - updatedTime() + updatedTime(source = PlayerEventSource.Player) } + Player.STATE_IDLE -> { - // IDLE + } + else -> Unit } } @@ -974,12 +1032,21 @@ class CS3IPlayer : IPlayer { // If the Network fails then ignore the exception if the duration is set. // This is to switch mirrors automatically if the stream has not been fetched, but // allow playing the buffer without internet as then the duration is fetched. - if (error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED - && exoPlayer?.duration != TIME_UNSET - ) { - exoPlayer?.prepare() - } else { - playerError?.invoke(error) + when { + error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + && exoPlayer?.duration != TIME_UNSET -> { + exoPlayer?.prepare() + } + + error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> { + // Re-initialize player at the current live window default position. + exoPlayer?.seekToDefaultPosition() + exoPlayer?.prepare() + } + + else -> { + event(ErrorEvent(error)) + } } super.onPlayerError(error) @@ -992,7 +1059,7 @@ class CS3IPlayer : IPlayer { override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) if (isPlaying) { - requestAutoFocus?.invoke() + event(RequestAudioFocusEvent()) onRenderFirst() } } @@ -1003,7 +1070,11 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { } + Player.STATE_ENDED -> { + // Resets subtitle delay on ended video + setSubtitleOffset(0) + // Only play next episode if autoplay is on (default) if (PreferenceManager.getDefaultSharedPreferences(context) ?.getBoolean( @@ -1011,42 +1082,49 @@ class CS3IPlayer : IPlayer { true ) == true ) { - handleEvent(CSPlayerEvent.NextEpisode) + handleEvent( + CSPlayerEvent.NextEpisode, + source = PlayerEventSource.Player + ) } } + Player.STATE_BUFFERING -> { - updatedTime() + updatedTime(source = PlayerEventSource.Player) } + Player.STATE_IDLE -> { // IDLE } + else -> Unit } } override fun onVideoSizeChanged(videoSize: VideoSize) { super.onVideoSizeChanged(videoSize) - playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height)) + event(ResizedEvent(height = videoSize.height, width = videoSize.width)) } override fun onRenderedFirstFrame() { - updatedTime() super.onRenderedFirstFrame() onRenderFirst() + updatedTime(source = PlayerEventSource.Player) } }) - } catch (e: Exception) { - Log.e(TAG, "loadExo error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadExo error", t) + event(ErrorEvent(t)) } } private var lastTimeStamps: List = emptyList() + override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> - updatedTime() + updatedTime(source = PlayerEventSource.Player) //if (payload is EpisodeSkip.SkipStamp) // this should always be true // onTimestampInvoked?.invoke(payload) } @@ -1056,46 +1134,47 @@ class CS3IPlayer : IPlayer { ?.setDeleteAfterDelivery(false) ?.send() } - updatedTime() + updatedTime(source = PlayerEventSource.Player) } fun onRenderFirst() { - if (!hasUsedFirstRender) { // this insures that we only call this once per player load - Log.i(TAG, "Rendered first frame") - val invalid = exoPlayer?.duration?.let { duration -> - // Only errors short playback when not playing downloaded files - duration < 20_000L && currentDownloadedFile == null - // Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period - // If you can get the total time that'd be better, but this is already niche. - && exoPlayer?.currentTimeline?.periodCount == 1 - && exoPlayer?.isCurrentMediaItemLive != true - } ?: false + if (hasUsedFirstRender) { // this insures that we only call this once per player load + return + } + Log.i(TAG, "Rendered first frame") + hasUsedFirstRender = true + val invalid = exoPlayer?.duration?.let { duration -> + // Only errors short playback when not playing downloaded files + duration < 20_000L && currentDownloadedFile == null + // Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period + // If you can get the total time that'd be better, but this is already niche. + && exoPlayer?.currentTimeline?.periodCount == 1 + && exoPlayer?.isCurrentMediaItemLive != true + } ?: false - if (invalid) { - releasePlayer(saveTime = false) - playerError?.invoke(InvalidFileException("Too short playback")) - return - } + if (invalid) { + releasePlayer(saveTime = false) + event(ErrorEvent(InvalidFileException("Too short playback"))) + return + } - setPreferredSubtitles(currentSubtitles) - hasUsedFirstRender = true - val format = exoPlayer?.videoFormat - val width = format?.width - val height = format?.height - if (height != null && width != null) { - playerDimensionsLoaded?.invoke(Pair(width, height)) - updatedTime() - exoPlayer?.apply { - requestedListeningPercentages?.forEach { percentage -> - createMessage { _, _ -> - updatedTime() - } - .setLooper(Looper.getMainLooper()) - .setPosition( /* positionMs= */contentDuration * percentage / 100) - // .setPayload(customPayloadData) - .setDeleteAfterDelivery(false) - .send() + setPreferredSubtitles(currentSubtitles) + val format = exoPlayer?.videoFormat + val width = format?.width + val height = format?.height + if (height != null && width != null) { + event(ResizedEvent(width = width, height = height)) + updatedTime() + exoPlayer?.apply { + requestedListeningPercentages?.forEach { percentage -> + createMessage { _, _ -> + updatedTime() } + .setLooper(Looper.getMainLooper()) + .setPosition(contentDuration * percentage / 100) + // .setPayload(customPayloadData) + .setDeleteAfterDelivery(false) + .send() } } } @@ -1118,9 +1197,9 @@ class CS3IPlayer : IPlayer { subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) - } catch (e: Exception) { - Log.e(TAG, "loadOfflinePlayer error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadOfflinePlayer error", t) + event(ErrorEvent(t)) } } @@ -1135,7 +1214,7 @@ class CS3IPlayer : IPlayer { .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) - .setSelectionFlags(SELECTION_FLAG_DEFAULT) + .setSelectionFlags(0) .build() when (sub.origin) { SubtitleOrigin.DOWNLOADED_FILE -> { @@ -1147,6 +1226,7 @@ class CS3IPlayer : IPlayer { null } } + SubtitleOrigin.URL -> { if (onlineSourceFactory != null) { activeSubtitles.add(sub) @@ -1159,6 +1239,7 @@ class CS3IPlayer : IPlayer { null } } + SubtitleOrigin.EMBEDDED_IN_VIDEO -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) @@ -1177,6 +1258,7 @@ class CS3IPlayer : IPlayer { return exoPlayer != null } + @SuppressLint("UnsafeOptInUsageError") private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { Log.i(TAG, "loadOnlinePlayer $link") try { @@ -1193,18 +1275,37 @@ class CS3IPlayer : IPlayer { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) } - val mime = if (link.isM3u8) { - MimeTypes.APPLICATION_M3U8 - } else { - MimeTypes.VIDEO_MP4 + val mime = when (link.type) { + ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 + ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD + ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4 + ExtractorLinkType.TORRENT -> throw IllegalArgumentException("No torrent support") + ExtractorLinkType.MAGNET -> throw IllegalArgumentException("No magnet support") } - val mediaItems = if (link is ExtractorLinkPlayList) { - link.playlist.map { + + val mediaItems = when (link) { + is ExtractorLinkPlayList -> link.playlist.map { MediaItemSlice(getMediaItem(mime, it.url), it.durationUs) } - } else { - listOf( + + is DrmExtractorLink -> { + listOf( + // Single sliced list with unset length + MediaItemSlice( + getMediaItem(mime, link.url), Long.MIN_VALUE, + drm = DrmMetadata( + kid = link.kid, + key = link.key, + uuid = link.uuid, + kty = link.kty, + keyRequestParameters = link.keyRequestParameters + ) + ) + ) + } + + else -> listOf( // Single sliced list with unset length MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) ) @@ -1230,20 +1331,20 @@ class CS3IPlayer : IPlayer { } loadExo(context, mediaItems, subSources, cacheFactory) - } catch (e: Exception) { - Log.e(TAG, "loadOnlinePlayer error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadOnlinePlayer error", t) + event(ErrorEvent(t)) } } override fun reloadPlayer(context: Context) { Log.i(TAG, "reloadPlayer") - exoPlayer?.release() + releasePlayer(false) currentLink?.let { loadOnlinePlayer(context, it) } ?: currentDownloadedFile?.let { loadOfflinePlayer(context, it) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 690d3706..07ce413e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -2,24 +2,38 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.util.Log +import androidx.annotation.OptIn import androidx.preference.PreferenceManager -import com.google.android.exoplayer2.Format -import com.google.android.exoplayer2.text.SubtitleDecoder -import com.google.android.exoplayer2.text.SubtitleDecoderFactory -import com.google.android.exoplayer2.text.SubtitleInputBuffer -import com.google.android.exoplayer2.text.SubtitleOutputBuffer -import com.google.android.exoplayer2.text.ssa.SsaDecoder -import com.google.android.exoplayer2.text.subrip.SubripDecoder -import com.google.android.exoplayer2.text.ttml.TtmlDecoder -import com.google.android.exoplayer2.text.webvtt.WebvttDecoder -import com.google.android.exoplayer2.util.MimeTypes +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.text.ExoplayerCuesDecoder +import androidx.media3.exoplayer.text.SubtitleDecoderFactory +import androidx.media3.extractor.text.SubtitleDecoder +import androidx.media3.extractor.text.SubtitleInputBuffer +import androidx.media3.extractor.text.SubtitleOutputBuffer +import androidx.media3.extractor.text.cea.Cea608Decoder +import androidx.media3.extractor.text.cea.Cea708Decoder +import androidx.media3.extractor.text.dvb.DvbDecoder +import androidx.media3.extractor.text.pgs.PgsDecoder +import androidx.media3.extractor.text.ssa.SsaDecoder +import androidx.media3.extractor.text.subrip.SubripDecoder +import androidx.media3.extractor.text.ttml.TtmlDecoder +import androidx.media3.extractor.text.tx3g.Tx3gDecoder +import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder +import androidx.media3.extractor.text.webvtt.WebvttDecoder import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import org.mozilla.universalchardet.UniversalDetector import java.nio.ByteBuffer import java.nio.charset.Charset -class CustomDecoder : SubtitleDecoder { +/** + * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not + * enough to identify the subtitle format. + **/ +@OptIn(UnstableApi::class) +class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { companion object { fun updateForcedEncoding(context: Context) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -59,7 +73,7 @@ class CustomDecoder : SubtitleDecoder { RegexOption.IGNORE_CASE ), ) - val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\d\s]*?[])}]\s*""")) + val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\s]*?[])}]\s*""")) //https://emptycharacter.com/ //https://www.fileformat.info/info/unicode/char/200b/index.htm @@ -139,7 +153,7 @@ class CustomDecoder : SubtitleDecoder { val inputString = getStr(inputBuffer) if (realDecoder == null && !inputString.isNullOrBlank()) { var str: String = inputString - // this way we read the subtitle file and decide what decoder to use instead of relying on mimetype + // this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype Log.i(TAG, "Got data from queueInputBuffer") //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 realDecoder = when { @@ -148,8 +162,31 @@ class CustomDecoder : SubtitleDecoder { (str.startsWith( "[Script Info]", ignoreCase = true - ) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder() + ) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder(fallbackFormat?.initializationData) str.startsWith("1", ignoreCase = true) -> SubripDecoder() + fallbackFormat != null -> { + when (val mimeType = fallbackFormat.sampleMimeType) { + MimeTypes.TEXT_VTT -> WebvttDecoder() + MimeTypes.TEXT_SSA -> SsaDecoder(fallbackFormat.initializationData) + MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttDecoder() + MimeTypes.APPLICATION_TTML -> TtmlDecoder() + MimeTypes.APPLICATION_SUBRIP -> SubripDecoder() + MimeTypes.APPLICATION_TX3G -> Tx3gDecoder(fallbackFormat.initializationData) + MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder( + mimeType, + fallbackFormat.accessibilityChannel, + Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS + ) + MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( + fallbackFormat.accessibilityChannel, + fallbackFormat.initializationData + ) + MimeTypes.APPLICATION_DVBSUBS -> DvbDecoder(fallbackFormat.initializationData) + MimeTypes.APPLICATION_PGS -> PgsDecoder() + MimeTypes.TEXT_EXOPLAYER_CUES -> ExoplayerCuesDecoder() + else -> null + } + } else -> null } Log.i( @@ -226,6 +263,7 @@ class CustomDecoder : SubtitleDecoder { } /** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */ +@OptIn(UnstableApi::class) class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { override fun supportsFormat(format: Format): Boolean { // return SubtitleDecoderFactory.DEFAULT.supportsFormat(format) @@ -246,28 +284,6 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { } override fun createDecoder(format: Format): SubtitleDecoder { - return CustomDecoder() - //return when (val mimeType = format.sampleMimeType) { - // MimeTypes.TEXT_VTT -> WebvttDecoder() - // MimeTypes.TEXT_SSA -> SsaDecoder(format.initializationData) - // MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttDecoder() - // MimeTypes.APPLICATION_TTML -> TtmlDecoder() - // MimeTypes.APPLICATION_SUBRIP -> SubripDecoder() - // MimeTypes.APPLICATION_TX3G -> Tx3gDecoder(format.initializationData) - // MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> return Cea608Decoder( - // mimeType, - // format.accessibilityChannel, - // Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS - // ) - // MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( - // format.accessibilityChannel, - // format.initializationData - // ) - // MimeTypes.APPLICATION_DVBSUBS -> DvbDecoder(format.initializationData) - // MimeTypes.APPLICATION_PGS -> PgsDecoder() - // MimeTypes.TEXT_EXOPLAYER_CUES -> ExoplayerCuesDecoder() - // // Default WebVttDecoder - // else -> WebvttDecoder() - //} + return CustomDecoder(format) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt index d3f4171a..f2b863fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt @@ -1,9 +1,12 @@ package com.lagradost.cloudstream3.ui.player import android.os.Looper -import com.google.android.exoplayer2.text.SubtitleDecoderFactory -import com.google.android.exoplayer2.text.TextOutput +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.text.SubtitleDecoderFactory +import androidx.media3.exoplayer.text.TextOutput +@OptIn(UnstableApi::class) class CustomTextRenderer( offset: Long, output: TextOutput?, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt index baf7ed52..c7db7d04 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -1,11 +1,15 @@ package com.lagradost.cloudstream3.ui.player +import android.net.Uri import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName +import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder import kotlin.math.max import kotlin.math.min @@ -14,6 +18,7 @@ class DownloadFileGenerator( private var currentIndex: Int = 0 ) : IGenerator { override val hasCache = false + override val canSkipLoading = false override fun hasNext(): Boolean { return currentIndex < episodes.size - 1 @@ -52,44 +57,50 @@ class DownloadFileGenerator( override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + type: LoadType, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int, + offset: Int ): Boolean { val meta = episodes[currentIndex + offset] - callback(Pair(null, meta)) - context?.let { ctx -> - val relative = meta.relativePath - val display = meta.displayName - - if (display == null || relative == null) { - return@let - } - VideoDownloadManager.getFolder(ctx, relative, meta.basePath) - ?.forEach { file -> - val name = display.removeSuffix(".mp4") - if (file.first != meta.displayName && file.first.startsWith(name)) { - val realName = file.first.removePrefix(name) - .removeSuffix(".vtt") - .removeSuffix(".srt") - .removeSuffix(".txt") - .trim() - .removePrefix("(") - .removeSuffix(")") - - subtitleCallback( - SubtitleData( - realName.ifBlank { ctx.getString(R.string.default_subtitles) }, - file.second.toString(), - SubtitleOrigin.DOWNLOADED_FILE, - name.toSubtitleMimeType(), - emptyMap() - ) - ) - } + if (meta.uri == Uri.EMPTY) { + // We do this here so that we only load it when + // we actually need it as it can be more expensive. + val info = meta.id?.let { id -> + activity?.let { act -> + getDownloadFileInfoAndUpdateSettings(act, id) } + } + + if (info != null) { + val newMeta = meta.copy(uri = info.path) + callback(null to newMeta) + } else callback(null to meta) + } else callback(null to meta) + + val ctx = context ?: return true + val relative = meta.relativePath ?: return true + val display = meta.displayName ?: return true + + val cleanDisplay = cleanDisplayName(display) + + getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { + val cleanName = cleanDisplayName(name) + val realName = cleanName.removePrefix(cleanDisplay) + + subtitleCallback( + SubtitleData( + realName.ifBlank { ctx.getString(R.string.default_subtitles) }, + uri.toString(), + SubtitleOrigin.DOWNLOADED_FILE, + name.toSubtitleMimeType(), + emptyMap(), + null + ) + ) + } } return true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index dc1bbba3..c38160c2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -1,21 +1,21 @@ package com.lagradost.cloudstream3.ui.player import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.Log import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity -import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.UIHelper.navigate - -const val DTAG = "PlayerActivity" +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink +import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback class DownloadedPlayerActivity : AppCompatActivity() { - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { + private val dTAG = "DownloadedPlayerAct" + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { CommonActivity.dispatchKeyEvent(this, event)?.let { return it } @@ -33,54 +33,18 @@ class DownloadedPlayerActivity : AppCompatActivity() { CommonActivity.onUserLeaveHint(this) } - override fun onBackPressed() { - finish() - } - - private fun playLink(url: String) { - this.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - LinkGenerator( - listOf( - url - ) - ) - ) - ) - } - - private fun playUri(uri: Uri) { - val name = UniFile.fromUri(this, uri).name - this.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator( - listOf( - ExtractorUri( - uri = uri, - name = name ?: getString(R.string.downloaded_file) - ) - ) - ) - ) - ) - } - override fun onCreate(savedInstanceState: Bundle?) { - Log.i(DTAG, "onCreate") - - CommonActivity.loadThemes(this) super.onCreate(savedInstanceState) + CommonActivity.loadThemes(this) CommonActivity.init(this) - setContentView(R.layout.empty_layout) + Log.i(dTAG, "onCreate") val data = intent.data if (intent?.action == Intent.ACTION_SEND) { - val extraText = try { // I dont trust android + val extraText = normalSafeApiCall { // I dont trust android intent.getStringExtra(Intent.EXTRA_TEXT) - } catch (e: Exception) { - null } val cd = intent.clipData val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null @@ -88,22 +52,29 @@ class DownloadedPlayerActivity : AppCompatActivity() { // idk what I am doing, just hope any of these work if (item?.uri != null) - playUri(item.uri) + playUri(this, item.uri) else if (url != null) - playLink(url) + playLink(this, url) else if (data != null) - playUri(data) + playUri(this, data) else if (extraText != null) - playLink(extraText) + playLink(this, extraText) else { finish() return } } else if (data?.scheme == "content") { - playUri(data) + playUri(this, data) } else { finish() return } + + attachBackPressedCallback { finish() } + } + + override fun onResume() { + super.onResume() + CommonActivity.setActivityInstance(this) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt index 7c19e97d..ec485f1c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -1,13 +1,13 @@ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri class ExtractorLinkGenerator( private val links: List, private val subtitles: List, ) : IGenerator { override val hasCache = false + override val canSkipLoading = true override fun getCurrentId(): Int? { return null @@ -37,14 +37,17 @@ class ExtractorLinkGenerator( override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + type: LoadType, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int ): Boolean { subtitles.forEach(subtitleCallback) + val allowedTypes = type.toSet() links.forEach { - callback.invoke(it to null) + if(allowedTypes.contains(it.type)) { + callback.invoke(it to null) + } } return true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 8d28fd9d..b2e80749 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -2,44 +2,61 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo import android.content.res.ColorStateList -import android.content.res.Resources +import android.content.res.Configuration import android.graphics.Color import android.media.AudioManager import android.os.Build import android.os.Bundle import android.provider.Settings import android.text.Editable -import android.util.DisplayMetrics +import android.text.format.DateUtils import android.view.KeyEvent +import android.view.LayoutInflater import android.view.MotionEvent +import android.view.Surface import android.view.View +import android.view.ViewGroup import android.view.WindowManager import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.AnimationUtils -import android.widget.EditText -import android.widget.ImageView -import android.widget.TextView +import androidx.annotation.OptIn +import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red +import androidx.core.view.children import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged +import androidx.media3.common.util.UnstableApi import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeight +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding +import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive -import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe @@ -49,31 +66,13 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate import com.lagradost.cloudstream3.utils.Vector2 -import kotlinx.android.synthetic.main.player_custom_layout.* -import kotlinx.android.synthetic.main.player_custom_layout.bottom_player_bar -import kotlinx.android.synthetic.main.player_custom_layout.exo_ffwd -import kotlinx.android.synthetic.main.player_custom_layout.exo_ffwd_text -import kotlinx.android.synthetic.main.player_custom_layout.exo_progress -import kotlinx.android.synthetic.main.player_custom_layout.exo_rew -import kotlinx.android.synthetic.main.player_custom_layout.exo_rew_text -import kotlinx.android.synthetic.main.player_custom_layout.player_center_menu -import kotlinx.android.synthetic.main.player_custom_layout.player_ffwd_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_pause_play -import kotlinx.android.synthetic.main.player_custom_layout.player_pause_play_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_left -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_left_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_left_icon -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_right -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_right_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_right_icon -import kotlinx.android.synthetic.main.player_custom_layout.player_rew_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_time_text -import kotlinx.android.synthetic.main.player_custom_layout.player_video_bar -import kotlinx.android.synthetic.main.player_custom_layout.shadow_overlay -import kotlinx.android.synthetic.main.trailer_custom_layout.* -import kotlin.math.* +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage @@ -83,40 +82,49 @@ const val HORIZONTAL_MULTIPLIER = 2.0f const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions +private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player open class FullScreenPlayer : AbstractPlayerFragment() { + private var isVerticalOrientation: Boolean = false protected open var lockRotation = true protected open var isFullScreenPlayer = true - protected open var isTv = false + protected var playerBinding: PlayerCustomLayoutBinding? = null + + private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) // state of player UI protected var isShowing = false protected var isLocked = false - //private var episodes: List = listOf() - protected fun setEpisodes(ep: List) { - //hasEpisodes = ep.size > 1 // if has 2 episodes or more because you dont want to switch to your current episode - //(player_episode_list?.adapter as? PlayerEpisodeAdapter?)?.updateList(ep) - } - protected var hasEpisodes = false private set //protected val hasEpisodes // get() = episodes.isNotEmpty() // options for player - protected var currentPrefQuality = - Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell + + /** + * Default profile 1 + * Decides how links should be sorted based on a priority system. + * This will be set in runtime based on settings. + **/ + protected var currentQualityProfile = 1 + + // protected var currentPrefQuality = +// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell protected var fastForwardTime = 10000L - protected var androidTVInterfaceOffSeekTime = 10000L; - protected var androidTVInterfaceOnSeekTime = 30000L; + protected var androidTVInterfaceOffSeekTime = 10000L + protected var androidTVInterfaceOnSeekTime = 30000L protected var swipeHorizontalEnabled = false protected var swipeVerticalEnabled = false protected var playBackSpeedEnabled = false protected var playerResizeEnabled = false protected var doubleTapEnabled = false protected var doubleTapPauseEnabled = true + protected var playerRotateEnabled = false + protected var autoPlayerRotateEnabled = false + private var hideControlsNames = false protected var subtitleDelay set(value) = try { @@ -135,19 +143,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { protected var useTrueSystemBrightness = true private val fullscreenNotch = true //TODO SETTING - protected val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics - - // screenWidth and screenHeight does always - // refer to the screen while in landscape mode - protected val screenWidth: Int - get() { - return max(displayMetrics.widthPixels, displayMetrics.heightPixels) - } - protected val screenHeight: Int - get() { - return min(displayMetrics.widthPixels, displayMetrics.heightPixels) - } - private var statusBarHeight: Int? = null private var navigationBarHeight: Int? = null @@ -174,6 +169,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { R.drawable.ic_baseline_volume_up_24, ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null + playerBinding = PlayerCustomLayoutBinding.bind(root.findViewById(R.id.player_holder)) + return root + } + + override fun onDestroyView() { + playerBinding = null + super.onDestroyView() + } + open fun showMirrorsDialogue() { throw NotImplementedError() } @@ -184,7 +194,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { open fun openOnlineSubPicker( context: Context, - imdbId: Long?, + loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { throw NotImplementedError() @@ -206,24 +216,24 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isShowing) { updateUIVisibility() } else { - player_holder?.postDelayed({ updateUIVisibility() }, 200) + playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - player_video_title?.let { + playerBinding?.playerVideoTitle?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } - player_video_title_rez?.let { + playerBinding?.playerVideoTitleRez?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() - bottom_player_bar?.let { + playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { duration = 200 start() @@ -236,10 +246,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() { fadeAnimation.duration = 100 fadeAnimation.fillAfter = true + @OptIn(UnstableApi::class) val sView = subView val sStyle = subStyle if (sView != null && sStyle != null) { - val move = if (isShowing) -((bottom_player_bar?.height?.toFloat() + val move = if (isShowing) -((playerBinding?.bottomPlayerBar?.height?.toFloat() ?: 0f) + 40.toPx) else -sStyle.elevation.toPx.toFloat() ObjectAnimator.ofFloat(sView, "translationY", move).apply { duration = 200 @@ -248,42 +259,118 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() - player_open_source?.let { - ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { - duration = 200 - start() + + playerBinding?.apply { + playerOpenSource.let { + ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { + duration = 200 + start() + } } + + if (!isLocked) { + playerFfwdHolder.alpha = 1f + playerRewHolder.alpha = 1f + // player_pause_play_holder?.alpha = 1f + shadowOverlay.isVisible = true + shadowOverlay.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) + + /*if (isBuffering) { + player_pause_play?.isVisible = false + player_pause_play_holder?.isVisible = false + } else { + player_pause_play?.isVisible = true + player_pause_play_holder?.startAnimation(fadeAnimation) + player_pause_play?.startAnimation(fadeAnimation) + }*/ + //player_buffering?.startAnimation(fadeAnimation) + } + + bottomPlayerBar.startAnimation(fadeAnimation) + playerOpenSource.startAnimation(fadeAnimation) + playerTopHolder.startAnimation(fadeAnimation) } - - - if (!isLocked) { - player_ffwd_holder?.alpha = 1f - player_rew_holder?.alpha = 1f - // player_pause_play_holder?.alpha = 1f - shadow_overlay?.isVisible = true - shadow_overlay?.startAnimation(fadeAnimation) - player_ffwd_holder?.startAnimation(fadeAnimation) - player_rew_holder?.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) - - /*if (isBuffering) { - player_pause_play?.isVisible = false - player_pause_play_holder?.isVisible = false - } else { - player_pause_play?.isVisible = true - player_pause_play_holder?.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) - }*/ - //player_buffering?.startAnimation(fadeAnimation) - } - - bottom_player_bar?.startAnimation(fadeAnimation) - player_open_source?.startAnimation(fadeAnimation) - player_top_holder?.startAnimation(fadeAnimation) } override fun subtitlesChanged() { - player_subtitle_offset_btt?.isGone = player.getCurrentPreferredSubtitle() == null + playerBinding?.playerSubtitleOffsetBtt?.isGone = + player.getCurrentPreferredSubtitle() == null + } + + private fun restoreOrientationWithSensor(activity: Activity) { + val currentOrientation = activity.resources.configuration.orientation + val orientation = when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + Configuration.ORIENTATION_PORTRAIT -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + else -> dynamicOrientation() + } + activity.requestedOrientation = orientation + } + + private fun toggleOrientationWithSensor(activity: Activity) { + val currentOrientation = activity.resources.configuration.orientation + val orientation: Int = when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + Configuration.ORIENTATION_PORTRAIT -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + else -> dynamicOrientation() + } + activity.requestedOrientation = orientation + } + + open fun lockOrientation(activity: Activity) { + @Suppress("DEPRECATION") + val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay + else activity.display!! + val rotation = display.rotation + val currentOrientation = activity.resources.configuration.orientation + val orientation: Int + when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + orientation = + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + else + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + Configuration.ORIENTATION_PORTRAIT -> + orientation = + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + + else -> orientation = dynamicOrientation() + } + activity.requestedOrientation = orientation + } + + private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) { + activity?.apply { + if (lockRotation) { + if (isLocked) { + lockOrientation(this) + } else { + if (ignoreDynamicOrientation) { + // restore when lock is disabled + restoreOrientationWithSensor(this) + } else { + this.requestedOrientation = dynamicOrientation() + } + } + } + } } protected fun enterFullscreen() { @@ -295,12 +382,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.window?.attributes = params } } - if (lockRotation) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + updateOrientation() } protected fun exitFullscreen() { - activity?.showSystemUI() //if (lockRotation) activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER @@ -312,6 +397,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT } activity?.window?.attributes = lp + activity?.showSystemUI() } override fun onResume() { @@ -328,8 +414,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun setPlayBackSpeed(speed: Float) { try { - setKey(PLAYBACK_SPEED_KEY, speed) - player_speed_btt?.text = + DataStoreHelper.playBackSpeed = speed + playerBinding?.playerSpeedBtt?.text = getString(R.string.player_speed_text_format).format(speed) .replace(".0x", "x") } catch (e: Exception) { @@ -345,67 +431,68 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } private fun showSubtitleOffsetDialog() { - context?.let { ctx -> - val builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - .setView(R.layout.subtitle_offset) - val dialog = builder.create() - dialog.show() + val ctx = context ?: return - val beforeOffset = subtitleDelay + val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) - val applyButton = dialog.findViewById(R.id.apply_btt)!! - val cancelButton = dialog.findViewById(R.id.cancel_btt)!! - val input = dialog.findViewById(R.id.subtitle_offset_input)!! - val sub = dialog.findViewById(R.id.subtitle_offset_subtract)!! - val subMore = dialog.findViewById(R.id.subtitle_offset_subtract_more)!! - val add = dialog.findViewById(R.id.subtitle_offset_add)!! - val addMore = dialog.findViewById(R.id.subtitle_offset_add_more)!! - val subTitle = dialog.findViewById(R.id.subtitle_offset_sub_title)!! + val builder = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + .setView(binding.root) + val dialog = builder.create() + dialog.show() - input.doOnTextChanged { text, _, _, _ -> - text?.toString()?.toLongOrNull()?.let { - subtitleDelay = it - when { - it > 0L -> { - context?.getString(R.string.subtitle_offset_extra_hint_later_format) - ?.format(it) + val beforeOffset = subtitleDelay + + /*val applyButton = dialog.findViewById(R.id.apply_btt)!! + val cancelButton = dialog.findViewById(R.id.cancel_btt)!! + val input = dialog.findViewById(R.id.subtitle_offset_input)!! + val sub = dialog.findViewById(R.id.subtitle_offset_subtract)!! + val subMore = dialog.findViewById(R.id.subtitle_offset_subtract_more)!! + val add = dialog.findViewById(R.id.subtitle_offset_add)!! + val addMore = dialog.findViewById(R.id.subtitle_offset_add_more)!! + val subTitle = dialog.findViewById(R.id.subtitle_offset_sub_title)!!*/ + binding.apply { + subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> + text?.toString()?.toLongOrNull()?.let { time -> + subtitleDelay = time + val str = when { + time > 0L -> { + txt(R.string.subtitle_offset_extra_hint_later_format, time) } - it < 0L -> { - context?.getString(R.string.subtitle_offset_extra_hint_before_format) - ?.format(-it) - } - it == 0L -> { - context?.getString(R.string.subtitle_offset_extra_hint_none_format) + + time < 0L -> { + txt(R.string.subtitle_offset_extra_hint_before_format, -time) } + else -> { - null + txt(R.string.subtitle_offset_extra_hint_none_format) } - }?.let { str -> - subTitle.text = str } + subtitleOffsetSubTitle.setText(str) } } - input.text = Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) + subtitleOffsetInput.text = + Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) val buttonChange = 100L val buttonChangeMore = 1000L fun changeBy(by: Long) { - val current = (input.text?.toString()?.toLongOrNull() ?: 0) + by - input.text = Editable.Factory.getInstance()?.newEditable(current.toString()) + val current = (subtitleOffsetInput.text?.toString()?.toLongOrNull() ?: 0) + by + subtitleOffsetInput.text = + Editable.Factory.getInstance()?.newEditable(current.toString()) } - add.setOnClickListener { + subtitleOffsetAdd.setOnClickListener { changeBy(buttonChange) } - addMore.setOnClickListener { + subtitleOffsetAddMore.setOnClickListener { changeBy(buttonChangeMore) } - sub.setOnClickListener { + subtitleOffsetSubtract.setOnClickListener { changeBy(-buttonChange) } - subMore.setOnClickListener { + subtitleOffsetSubtractMore.setOnClickListener { changeBy(-buttonChangeMore) } @@ -413,17 +500,23 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isFullScreenPlayer) activity?.hideSystemUI() } - applyButton.setOnClickListener { + applyBtt.setOnClickListener { dialog.dismissSafe(activity) player.seekTime(1L) } - cancelButton.setOnClickListener { + resetBtt.setOnClickListener { + subtitleDelay = 0 + dialog.dismissSafe(activity) + player.seekTime(1L) + } + cancelBtt.setOnClickListener { subtitleDelay = beforeOffset dialog.dismissSafe(activity) } } } + private fun showSpeedDialog() { val speedsText = listOf( @@ -460,39 +553,42 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } fun resetRewindText() { - exo_rew_text?.text = + playerBinding?.exoRewText?.text = getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) } fun resetFastForwardText() { - exo_ffwd_text?.text = + playerBinding?.exoFfwdText?.text = getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) } private fun rewind() { try { - player_center_menu?.isGone = false - player_rew_holder?.alpha = 1f + playerBinding?.apply { + playerCenterMenu.isGone = false + playerRewHolder.alpha = 1f - val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - exo_rew?.startAnimation(rotateLeft) + val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) + exoRew.startAnimation(rotateLeft) - val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) - goLeft.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - exo_rew_text?.post { - resetRewindText() - player_center_menu?.isGone = !isShowing - player_rew_holder?.alpha = if (isShowing) 1f else 0f + override fun onAnimationEnd(animation: Animation?) { + exoRewText.post { + resetRewindText() + playerCenterMenu.isGone = !isShowing + playerRewHolder.alpha = if (isShowing) 1f else 0f + } } - } - }) - exo_rew_text?.startAnimation(goLeft) - exo_rew_text?.text = getString(R.string.rew_text_format).format(fastForwardTime / 1000) + }) + exoRewText.startAnimation(goLeft) + exoRewText.text = + getString(R.string.rew_text_format).format(fastForwardTime / 1000) + } player.seekTime(-fastForwardTime) } catch (e: Exception) { logError(e) @@ -501,28 +597,31 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun fastForward() { try { - player_center_menu?.isGone = false - player_ffwd_holder?.alpha = 1f + playerBinding?.apply { + playerCenterMenu.isGone = false + playerFfwdHolder.alpha = 1f - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - exo_ffwd?.startAnimation(rotateRight) + val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) + exoFfwd.startAnimation(rotateRight) - val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) - goRight.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - exo_ffwd_text?.post { - resetFastForwardText() - player_center_menu?.isGone = !isShowing - player_ffwd_holder?.alpha = if (isShowing) 1f else 0f + override fun onAnimationEnd(animation: Animation?) { + exoFfwdText.post { + resetFastForwardText() + playerCenterMenu.isGone = !isShowing + playerFfwdHolder.alpha = if (isShowing) 1f else 0f + } } - } - }) - exo_ffwd_text?.startAnimation(goRight) - exo_ffwd_text?.text = getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + }) + exoFfwdText.startAnimation(goRight) + exoFfwdText.text = + getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + } player.seekTime(fastForwardTime) } catch (e: Exception) { logError(e) @@ -532,13 +631,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun onClickChange() { isShowing = !isShowing if (isShowing) { - player_intro_play?.isGone = true + playerBinding?.playerIntroPlay?.isGone = true autoHide() } if (isFullScreenPlayer) activity?.hideSystemUI() animateLayoutChanges() - player_pause_play?.requestFocus() + playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { @@ -547,8 +646,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + updateOrientation(true) // set true to ignore auto rotate to stay in current orientation + if (isLocked && isShowing) { - player_holder?.postDelayed({ + playerBinding?.playerHolder?.postDelayed({ if (isLocked && isShowing) { onClickChange() } @@ -556,36 +657,36 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + playerBinding?.apply { + val fadeAnimation = AlphaAnimation(playerVideoTitle.alpha, fadeTo).apply { + duration = 100 + fillAfter = true + } - val fadeAnimation = AlphaAnimation(player_video_title.alpha, fadeTo).apply { - duration = 100 - fillAfter = true + updateUIVisibility() + // MENUS + //centerMenu.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) + + //if (hasEpisodes) + // player_episodes_button?.startAnimation(fadeAnimation) + //player_media_route_button?.startAnimation(fadeAnimation) + //video_bar.startAnimation(fadeAnimation) + + //TITLE + playerVideoTitleRez.startAnimation(fadeAnimation) + playerEpisodeFiller.startAnimation(fadeAnimation) + playerVideoTitle.startAnimation(fadeAnimation) + playerTopHolder.startAnimation(fadeAnimation) + // BOTTOM + playerLockHolder.startAnimation(fadeAnimation) + //player_go_back_holder?.startAnimation(fadeAnimation) + + shadowOverlay.isVisible = true + shadowOverlay.startAnimation(fadeAnimation) } - - updateUIVisibility() - // MENUS - //centerMenu.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) - player_ffwd_holder?.startAnimation(fadeAnimation) - player_rew_holder?.startAnimation(fadeAnimation) - - //if (hasEpisodes) - // player_episodes_button?.startAnimation(fadeAnimation) - //player_media_route_button?.startAnimation(fadeAnimation) - //video_bar.startAnimation(fadeAnimation) - - //TITLE - player_video_title_rez?.startAnimation(fadeAnimation) - player_episode_filler?.startAnimation(fadeAnimation) - player_video_title?.startAnimation(fadeAnimation) - player_top_holder?.startAnimation(fadeAnimation) - // BOTTOM - player_lock_holder?.startAnimation(fadeAnimation) - //player_go_back_holder?.startAnimation(fadeAnimation) - - shadow_overlay?.isVisible = true - shadow_overlay?.startAnimation(fadeAnimation) - updateLockUI() } @@ -599,34 +700,39 @@ open class FullScreenPlayer : AbstractPlayerFragment() { togglePlayerTitleGone = true } } - player_lock_holder?.isGone = isGone - player_video_bar?.isGone = isGone - player_pause_play_holder?.isGone = isGone - player_pause_play?.isGone = isGone - //player_buffering?.isGone = isGone - player_top_holder?.isGone = isGone - //player_episodes_button?.isVisible = !isGone && hasEpisodes - player_video_title?.isGone = togglePlayerTitleGone + playerBinding?.apply { + + playerLockHolder.isGone = isGone + playerVideoBar.isGone = isGone + + playerPausePlay.isGone = isGone + //player_buffering?.isGone = isGone + playerTopHolder.isGone = isGone + //player_episodes_button?.isVisible = !isGone && hasEpisodes + playerVideoTitle.isGone = togglePlayerTitleGone // player_video_title_rez?.isGone = isGone - player_episode_filler?.isGone = isGone - player_center_menu?.isGone = isGone - player_lock?.isGone = !isShowing - //player_media_route_button?.isClickable = !isGone - player_go_back_holder?.isGone = isGone - player_sources_btt?.isGone = isGone - player_skip_episode?.isClickable = !isGone + playerEpisodeFiller.isGone = isGone + playerCenterMenu.isGone = isGone + playerLock.isGone = !isShowing + //player_media_route_button?.isClickable = !isGone + playerGoBackHolder.isGone = isGone + playerSourcesBtt.isGone = isGone + playerSkipEpisode.isClickable = !isGone + } } private fun updateLockUI() { - player_lock?.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - if (layout == R.layout.fragment_player) { - val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) - else Color.WHITE - if (color != null) { - player_lock?.setTextColor(color) - player_lock?.iconTint = ColorStateList.valueOf(color) - player_lock?.rippleColor = - ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) + playerBinding?.apply { + playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) + if (layout == R.layout.fragment_player) { + val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) + else Color.WHITE + if (color != null) { + playerLock.setTextColor(color) + playerLock.iconTint = ColorStateList.valueOf(color) + playerLock.rippleColor = + ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) + } } } } @@ -634,8 +740,17 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private var currentTapIndex = 0 protected fun autoHide() { currentTapIndex++ + delayHide() + } + + override fun playerStatusChanged() { + super.playerStatusChanged() + delayHide() + } + + private fun delayHide() { val index = currentTapIndex - player_holder?.postDelayed({ + playerBinding?.playerHolder?.postDelayed({ if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { onClickChange() } @@ -647,7 +762,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun toggleShowDelayed() { if (doubleTapEnabled || doubleTapPauseEnabled) { val index = currentDoubleTapIndex - player_holder?.postDelayed({ + playerBinding?.playerHolder?.postDelayed({ if (index == currentDoubleTapIndex) { onClickChange() } @@ -778,249 +893,270 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (event == null || view == null) return false val currentTouch = Vector2(event.x, event.y) val startTouch = currentTouchStart - player_intro_play?.isGone = true - when (event.action) { - MotionEvent.ACTION_DOWN -> { - // validates if the touch is inside of the player area - isCurrentTouchValid = isValidTouch(currentTouch.x, currentTouch.y) - /*if (isCurrentTouchValid && player_episode_list?.isVisible == true) { - player_episode_list?.isVisible = false - } else*/ if (isCurrentTouchValid) { - currentTouchStartTime = System.currentTimeMillis() - currentTouchStart = currentTouch - currentTouchLast = currentTouch - currentTouchStartPlayerTime = player.getPosition() - getBrightness()?.let { - currentRequestedBrightness = it - } - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - val currentVolume = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolume = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + playerBinding?.apply { + playerIntroPlay.isGone = true - currentRequestedVolume = currentVolume.toFloat() / maxVolume.toFloat() - } - } - } - MotionEvent.ACTION_UP -> { - if (isCurrentTouchValid && !isLocked && isFullScreenPlayer) { - // seek time - if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time) { - val startTime = currentTouchStartPlayerTime - if (startTime != null) { - calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> - if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { - player.seekTo(seekTo) - } - } + when (event.action) { + MotionEvent.ACTION_DOWN -> { + // validates if the touch is inside of the player area + isCurrentTouchValid = isValidTouch(currentTouch.x, currentTouch.y) + /*if (isCurrentTouchValid && player_episode_list?.isVisible == true) { + player_episode_list?.isVisible = false + } else*/ if (isCurrentTouchValid) { + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = player.getPosition() + + getBrightness()?.let { + currentRequestedBrightness = it + } + (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> + val currentVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + currentRequestedVolume = currentVolume.toFloat() / maxVolume.toFloat() } } } - // see if click is eligible for seek 10s - val holdTime = currentTouchStartTime?.minus(System.currentTimeMillis()) - if (isCurrentTouchValid // is valid - && currentTouchAction == null // no other action like swiping is taking place - && currentLastTouchAction == null // last action was none, this prevents mis input random seek - && holdTime != null - && holdTime < DOUBLE_TAB_MAXIMUM_HOLD_TIME // it is a click not a long hold - ) { - if (!isLocked - && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short - ) { - currentClickCount++ - - if (currentClickCount >= 1) { // have double clicked - currentDoubleTapIndex++ - if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen - when { - currentTouch.x < screenWidth / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - rewind() + MotionEvent.ACTION_UP -> { + if (isCurrentTouchValid && !isLocked && isFullScreenPlayer) { + // seek time + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime( + startTime, + startTouch, + currentTouch + )?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + player.seekTo(seekTo, PlayerEventSource.UI) } - currentTouch.x > screenWidth / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - fastForward() - } - else -> { - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - } - } else if (doubleTapEnabled && isFullScreenPlayer) { - if (currentTouch.x < screenWidth / 2) { - rewind() - } else { - fastForward() } } } + } + + // see if click is eligible for seek 10s + val holdTime = currentTouchStartTime?.minus(System.currentTimeMillis()) + if (isCurrentTouchValid // is valid + && currentTouchAction == null // no other action like swiping is taking place + && currentLastTouchAction == null // last action was none, this prevents mis input random seek + && holdTime != null + && holdTime < DOUBLE_TAB_MAXIMUM_HOLD_TIME // it is a click not a long hold + ) { + if (!isLocked + && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short + ) { + currentClickCount++ + + if (currentClickCount >= 1) { // have double clicked + currentDoubleTapIndex++ + if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen + when { + currentTouch.x < screenWidth / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { + if (doubleTapEnabled) + rewind() + } + + currentTouch.x > screenWidth / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { + if (doubleTapEnabled) + fastForward() + } + + else -> { + player.handleEvent( + CSPlayerEvent.PlayPauseToggle, + PlayerEventSource.UI + ) + } + } + } else if (doubleTapEnabled && isFullScreenPlayer) { + if (currentTouch.x < screenWidth / 2) { + rewind() + } else { + fastForward() + } + } + } + } else { + // is a valid click but not fast enough for seek + currentClickCount = 0 + toggleShowDelayed() + //onClickChange() + } } else { - // is a valid click but not fast enough for seek currentClickCount = 0 - toggleShowDelayed() - //onClickChange() } - } else { - currentClickCount = 0 + + // call auto hide as it wont hide when you have your finger down + autoHide() + + // reset variables + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + + // resets UI + playerTimeText.isVisible = false + playerProgressbarLeftHolder.isVisible = false + playerProgressbarRightHolder.isVisible = false + + currentLastTouchEndTime = System.currentTimeMillis() } - // call auto hide as it wont hide when you have your finger down - autoHide() + MotionEvent.ACTION_MOVE -> { + // if current touch is valid + if (startTouch != null && isCurrentTouchValid && !isLocked && isFullScreenPlayer) { + // action is unassigned and can therefore be assigned + if (currentTouchAction == null) { + val diffFromStart = startTouch - currentTouch - // reset variables - isCurrentTouchValid = false - currentTouchStart = null - currentLastTouchAction = currentTouchAction - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeight) > MINIMUM_VERTICAL_SWIPE) { + // left = Brightness, right = Volume, but the UI is reversed to show the UI better + currentTouchAction = if (startTouch.x < screenWidth / 2) { + // hide the UI if you hold brightness to show screen better, better UX + if (isShowing) { + isShowing = false + animateLayoutChanges() + } - // resets UI - player_time_text?.isVisible = false - player_progressbar_left_holder?.isVisible = false - player_progressbar_right_holder?.isVisible = false - currentLastTouchEndTime = System.currentTimeMillis() - } - MotionEvent.ACTION_MOVE -> { - // if current touch is valid - if (startTouch != null && isCurrentTouchValid && !isLocked && isFullScreenPlayer) { - // action is unassigned and can therefore be assigned - if (currentTouchAction == null) { - val diffFromStart = startTouch - currentTouch - - if (swipeVerticalEnabled) { - if (abs(diffFromStart.y * 100 / screenHeight) > MINIMUM_VERTICAL_SWIPE) { - // left = Brightness, right = Volume, but the UI is reversed to show the UI better - currentTouchAction = if (startTouch.x < screenWidth / 2) { - // hide the UI if you hold brightness to show screen better, better UX - if (isShowing) { - isShowing = false - animateLayoutChanges() - } - - TouchAction.Brightness - } else { - TouchAction.Volume - } - } - } - if (swipeHorizontalEnabled) { - if (abs(diffFromStart.x * 100 / screenHeight) > MINIMUM_HORIZONTAL_SWIPE) { - currentTouchAction = TouchAction.Time - } - } - } - - // display action - val lastTouch = currentTouchLast - if (lastTouch != null) { - val diffFromLast = lastTouch - currentTouch - val verticalAddition = - diffFromLast.y * VERTICAL_MULTIPLIER / screenHeight.toFloat() - - // update UI - player_time_text?.isVisible = false - player_progressbar_left_holder?.isVisible = false - player_progressbar_right_holder?.isVisible = false - - when (currentTouchAction) { - TouchAction.Time -> { - // this simply updates UI as the seek logic happens on release - // startTime is rounded to make the UI sync in a nice way - val startTime = - currentTouchStartPlayerTime?.div(1000L)?.times(1000L) - if (startTime != null) { - calculateNewTime( - startTime, - startTouch, - currentTouch - )?.let { newMs -> - val skipMs = newMs - startTime - player_time_text?.text = - "${convertTimeToString(newMs / 1000)} [${ - (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) - }${convertTimeToString(abs(skipMs / 1000))}]" - player_time_text?.isVisible = true + TouchAction.Brightness + } else { + TouchAction.Volume } } } - TouchAction.Brightness -> { - player_progressbar_right_holder?.isVisible = true - val lastRequested = currentRequestedBrightness - currentRequestedBrightness = - min( - 1.0f, - max(currentRequestedBrightness + verticalAddition, 0.0f) - ) - - // this is to not spam request it, just in case it fucks over someone - if (lastRequested != currentRequestedBrightness) - setBrightness(currentRequestedBrightness) - - // max is set high to make it smooth - player_progressbar_right?.max = 100_000 - player_progressbar_right?.progress = - max(2_000, (currentRequestedBrightness * 100_000f).toInt()) - - player_progressbar_right_icon?.setImageResource( - brightnessIcons[min( // clamp the value just in case - brightnessIcons.size - 1, - max( - 0, - round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() - ) - )] - ) + if (swipeHorizontalEnabled) { + if (abs(diffFromStart.x * 100 / screenHeight) > MINIMUM_HORIZONTAL_SWIPE) { + currentTouchAction = TouchAction.Time + } } - TouchAction.Volume -> { - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - player_progressbar_left_holder?.isVisible = true - val maxVolume = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val currentVolume = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + } - // clamps volume and adds swipe - currentRequestedVolume = + // display action + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = + diffFromLast.y * VERTICAL_MULTIPLIER / screenHeight.toFloat() + + // update UI + playerTimeText.isVisible = false + playerProgressbarLeftHolder.isVisible = false + playerProgressbarRightHolder.isVisible = false + + when (currentTouchAction) { + TouchAction.Time -> { + // this simply updates UI as the seek logic happens on release + // startTime is rounded to make the UI sync in a nice way + val startTime = + currentTouchStartPlayerTime?.div(1000L)?.times(1000L) + if (startTime != null) { + calculateNewTime( + startTime, + startTouch, + currentTouch + )?.let { newMs -> + val skipMs = newMs - startTime + playerTimeText.apply { + text = + "${convertTimeToString(newMs / 1000)} [${ + (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) + }${convertTimeToString(abs(skipMs / 1000))}]" + isVisible = true + } + } + } + } + + TouchAction.Brightness -> { + playerProgressbarRightHolder.isVisible = true + val lastRequested = currentRequestedBrightness + currentRequestedBrightness = min( 1.0f, - max(currentRequestedVolume + verticalAddition, 0.0f) + max(currentRequestedBrightness + verticalAddition, 0.0f) ) - // max is set high to make it smooth - player_progressbar_left?.max = 100_000 - player_progressbar_left?.progress = - max(2_000, (currentRequestedVolume * 100_000f).toInt()) + // this is to not spam request it, just in case it fucks over someone + if (lastRequested != currentRequestedBrightness) + setBrightness(currentRequestedBrightness) - player_progressbar_left_icon?.setImageResource( - volumeIcons[min( // clamp the value just in case - volumeIcons.size - 1, + // max is set high to make it smooth + playerProgressbarRight.max = 100_000 + playerProgressbarRight.progress = + max(2_000, (currentRequestedBrightness * 100_000f).toInt()) + + playerProgressbarRightIcon.setImageResource( + brightnessIcons[min( // clamp the value just in case + brightnessIcons.size - 1, max( 0, - round(currentRequestedVolume * (volumeIcons.size - 1)).toInt() + round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() ) )] ) + } - // this is used instead of set volume because old devices does not support it - val desiredVolume = - round(currentRequestedVolume * maxVolume).toInt() - if (desiredVolume != currentVolume) { - val newVolumeAdjusted = - if (desiredVolume < currentVolume) AudioManager.ADJUST_LOWER else AudioManager.ADJUST_RAISE + TouchAction.Volume -> { + (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> + playerProgressbarLeftHolder.isVisible = true + val maxVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val currentVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - newVolumeAdjusted, - 0 + // clamps volume and adds swipe + currentRequestedVolume = + min( + 1.0f, + max(currentRequestedVolume + verticalAddition, 0.0f) + ) + + // max is set high to make it smooth + playerProgressbarLeft.max = 100_000 + playerProgressbarLeft.progress = + max(2_000, (currentRequestedVolume * 100_000f).toInt()) + + playerProgressbarLeftIcon.setImageResource( + volumeIcons[min( // clamp the value just in case + volumeIcons.size - 1, + max( + 0, + round(currentRequestedVolume * (volumeIcons.size - 1)).toInt() + ) + )] ) + + // this is used instead of set volume because old devices does not support it + val desiredVolume = + round(currentRequestedVolume * maxVolume).toInt() + if (desiredVolume != currentVolume) { + val newVolumeAdjusted = + if (desiredVolume < currentVolume) AudioManager.ADJUST_LOWER else AudioManager.ADJUST_RAISE + + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + newVolumeAdjusted, + 0 + ) + } } } + + else -> Unit } - else -> Unit } } } @@ -1030,6 +1166,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return true } + @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -1045,26 +1182,30 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return true } } + + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP -> { if (!isShowing) { onClickChange() return true } } + KeyEvent.KEYCODE_DPAD_LEFT -> { if (!isShowing && !isLocked) { player.seekTime(-androidTVInterfaceOffSeekTime) return true - } else if (player_pause_play?.isFocused == true) { + } else if (playerBinding?.playerPausePlay?.isFocused == true) { player.seekTime(-androidTVInterfaceOnSeekTime) return true } } + KeyEvent.KEYCODE_DPAD_RIGHT -> { if (!isShowing && !isLocked) { player.seekTime(androidTVInterfaceOffSeekTime) return true - } else if (player_pause_play?.isFocused == true) { + } else if (playerBinding?.playerPausePlay?.isFocused == true) { player.seekTime(androidTVInterfaceOnSeekTime) return true } @@ -1091,7 +1232,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // netflix capture back and hide ~monke KeyEvent.KEYCODE_BACK -> { - if (isShowing && isTv) { + if (isShowing && isLayout(TV or EMULATOR)) { onClickChange() return true } @@ -1107,11 +1248,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { isShowing = false // if nothing has loaded these buttons should not be visible - player_skip_episode?.isVisible = false - player_tracks_btt?.isVisible = false - player_skip_op?.isVisible = false - shadow_overlay?.isVisible = false - + playerBinding?.apply { + playerSkipEpisode.isVisible = false + playerGoForward.isVisible = false + playerTracksBtt.isVisible = false + playerSkipOp.isVisible = false + shadowOverlay.isVisible = false + } updateLockUI() updateUIVisibility() animateLayoutChanges() @@ -1119,11 +1262,20 @@ open class FullScreenPlayer : AbstractPlayerFragment() { resetRewindText() } + override fun onSaveInstanceState(outState: Bundle) { + // As this is video specific it is better to not do any setKey/getKey + outState.putLong(SUBTITLE_DELAY_BUNDLE_KEY, subtitleDelay) + super.onSaveInstanceState(outState) + } + @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // init variables - setPlayBackSpeed(getKey(PLAYBACK_SPEED_KEY) ?: 1.0f) + setPlayBackSpeed(DataStoreHelper.playBackSpeed) + savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { + subtitleDelay = it + } // handle tv controls playerEventListener = { eventType -> @@ -1131,50 +1283,69 @@ open class FullScreenPlayer : AbstractPlayerFragment() { PlayerEventType.Lock -> { toggleLock() } + PlayerEventType.NextEpisode -> { player.handleEvent(CSPlayerEvent.NextEpisode) } + PlayerEventType.Pause -> { player.handleEvent(CSPlayerEvent.Pause) } + PlayerEventType.PlayPauseToggle -> { player.handleEvent(CSPlayerEvent.PlayPauseToggle) } + PlayerEventType.Play -> { player.handleEvent(CSPlayerEvent.Play) } + PlayerEventType.SkipCurrentChapter -> { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } + PlayerEventType.Resize -> { nextResize() } + PlayerEventType.PrevEpisode -> { player.handleEvent(CSPlayerEvent.PrevEpisode) } + PlayerEventType.SeekForward -> { player.handleEvent(CSPlayerEvent.SeekForward) } + PlayerEventType.ShowSpeed -> { showSpeedDialog() } + PlayerEventType.SeekBack -> { player.handleEvent(CSPlayerEvent.SeekBack) } + + PlayerEventType.Restart -> { + player.handleEvent(CSPlayerEvent.Restart) + } + PlayerEventType.ToggleMute -> { player.handleEvent(CSPlayerEvent.ToggleMute) } + PlayerEventType.ToggleHide -> { onClickChange() } + PlayerEventType.ShowMirrors -> { showMirrorsDialogue() } + PlayerEventType.SearchSubtitlesOnline -> { if (subsProvidersIsActive) { openOnlineSubPicker(view.context, null) {} } } + PlayerEventType.SkipOp -> { skipOp() } @@ -1192,15 +1363,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } else false } - //player_episodes_button?.setOnClickListener { - // player_episodes_button?.isGone = true - // player_episode_list?.isVisible = true - //} -// - //player_episode_list?.adapter = PlayerEpisodeAdapter { click -> -// - //} - try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) @@ -1210,10 +1372,16 @@ open class FullScreenPlayer : AbstractPlayerFragment() { .toLong() * 1000L androidTVInterfaceOffSeekTime = - settingsManager.getInt(ctx.getString(R.string.android_tv_interface_off_seek_key), 10) + settingsManager.getInt( + ctx.getString(R.string.android_tv_interface_off_seek_key), + 10 + ) .toLong() * 1000L androidTVInterfaceOnSeekTime = - settingsManager.getInt(ctx.getString(R.string.android_tv_interface_on_seek_key), 10) + settingsManager.getInt( + ctx.getString(R.string.android_tv_interface_on_seek_key), + 10 + ) .toLong() * 1000L navigationBarHeight = ctx.getNavigationBarHeight() @@ -1230,6 +1398,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ctx.getString(R.string.playback_speed_enabled_key), false ) + playerRotateEnabled = settingsManager.getBoolean( + ctx.getString(R.string.rotate_video_key), + false + ) + autoPlayerRotateEnabled = settingsManager.getBoolean( + ctx.getString(R.string.auto_rotate_video_key), + false + ) playerResizeEnabled = settingsManager.getBoolean( ctx.getString(R.string.player_resize_enabled_key), @@ -1247,129 +1423,231 @@ open class FullScreenPlayer : AbstractPlayerFragment() { false ) - currentPrefQuality = settingsManager.getInt( - ctx.getString(R.string.quality_pref_key), - currentPrefQuality - ) + hideControlsNames = settingsManager.getBoolean(ctx.getString(R.string.hide_player_control_names_key), false) + + val profiles = QualityDataHelper.getProfiles() + val type = if (ctx.isUsingMobileData()) + QualityDataHelper.QualityProfileType.Data + else QualityDataHelper.QualityProfileType.WiFi + + currentQualityProfile = + profiles.firstOrNull { it.type == type }?.id ?: profiles.firstOrNull()?.id + ?: currentQualityProfile + +// currentPrefQuality = settingsManager.getInt( +// ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), +// currentPrefQuality +// ) // useSystemBrightness = // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) } - - player_speed_btt?.isVisible = playBackSpeedEnabled - player_resize_btt?.isVisible = playerResizeEnabled + playerBinding?.apply { + playerSpeedBtt.isVisible = playBackSpeedEnabled + playerResizeBtt.isVisible = playerResizeEnabled + playerRotateBtt.isVisible = playerRotateEnabled + if (hideControlsNames) { + hideControlsNames() + } + } } catch (e: Exception) { logError(e) } - player_pause_play?.setOnClickListener { - autoHide() - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } + playerBinding?.apply { - skip_chapter_button?.setOnClickListener { - player.handleEvent(CSPlayerEvent.SkipCurrentChapter) - } - - // init clicks - player_resize_btt?.setOnClickListener { - autoHide() - nextResize() - } - - player_speed_btt?.setOnClickListener { - autoHide() - showSpeedDialog() - } - - player_skip_op?.setOnClickListener { - autoHide() - skipOp() - } - - player_skip_episode?.setOnClickListener { - autoHide() - player.handleEvent(CSPlayerEvent.NextEpisode) - } - - player_lock?.setOnClickListener { - autoHide() - toggleLock() - } - - player_subtitle_offset_btt?.setOnClickListener { - showSubtitleOffsetDialog() - } - - exo_rew?.setOnClickListener { - autoHide() - rewind() - } - - exo_ffwd?.setOnClickListener { - autoHide() - fastForward() - } - - player_go_back?.setOnClickListener { - activity?.popCurrentPage() - } - - player_sources_btt?.setOnClickListener { - showMirrorsDialogue() - } - - player_tracks_btt?.setOnClickListener { - showTracksDialogue() - } - - // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar - player_holder?.setOnTouchListener { callView, event -> - return@setOnTouchListener handleMotionEvent(callView, event) - } - - exo_progress?.setOnTouchListener { _, event -> - // this makes the bar not disappear when sliding - when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ - } - MotionEvent.ACTION_MOVE -> { - currentTapIndex++ - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { - autoHide() + if (isLayout(TV or EMULATOR)) { + mapOf( + playerGoBack to playerGoBackText, + playerRestart to playerRestartText, + playerGoForward to playerGoForwardText + ).forEach { (button, text) -> + button.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + text.isSelected = false + text.isVisible = false + return@setOnFocusChangeListener + } + text.isSelected = true + text.isVisible = true + } } } - return@setOnTouchListener false + + playerPausePlay.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + exoDuration.setOnClickListener { + setRemainingTimeCounter(true) + } + + timeLeft.setOnClickListener { + setRemainingTimeCounter(false) + } + + skipChapterButton.setOnClickListener { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } + + playerRotateBtt.setOnClickListener { + autoHide() + toggleRotate() + } + + // init clicks + playerResizeBtt.setOnClickListener { + autoHide() + nextResize() + } + + playerSpeedBtt.setOnClickListener { + autoHide() + showSpeedDialog() + } + + playerSkipOp.setOnClickListener { + autoHide() + skipOp() + } + + playerSkipEpisode.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + playerGoForward.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + playerRestart.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.Restart) + } + + playerLock.setOnClickListener { + autoHide() + toggleLock() + } + + playerSubtitleOffsetBtt.setOnClickListener { + showSubtitleOffsetDialog() + } + + exoRew.setOnClickListener { + autoHide() + rewind() + } + + exoFfwd.setOnClickListener { + autoHide() + fastForward() + } + + playerGoBack.setOnClickListener { + activity?.popCurrentPage() + } + + playerSourcesBtt.setOnClickListener { + showMirrorsDialogue() + } + + playerTracksBtt.setOnClickListener { + showTracksDialogue() + } + + // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar + playerHolder.setOnTouchListener { callView, event -> + return@setOnTouchListener handleMotionEvent(callView, event) + } + + exoProgress.setOnTouchListener { _, event -> + // this makes the bar not disappear when sliding + when (event.action) { + MotionEvent.ACTION_DOWN -> { + currentTapIndex++ + } + + MotionEvent.ACTION_MOVE -> { + currentTapIndex++ + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { + autoHide() + } + } + return@setOnTouchListener false + } + } + // cs3 is peak media center + setRemainingTimeCounter(durationMode || isLayout(TV)) + playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> + updateRemainingTime() } // init UI try { uiReset() - - // init chromecast UI - // removed due to having no use and bugging - //activity?.let { - // if (it.isCastApiAvailable()) { - // try { - // CastButtonFactory.setUpMediaRouteButton(it, player_media_route_button) - // val castContext = CastContext.getSharedInstance(it.applicationContext) - // - // player_media_route_button?.isGone = - // castContext.castState == CastState.NO_DEVICES_AVAILABLE - // castContext.addCastStateListener { state -> - // player_media_route_button?.isGone = - // state == CastState.NO_DEVICES_AVAILABLE - // } - // } catch (e: Exception) { - // logError(e) - // } - // } else { - // // if cast is not possible hide UI - // player_media_route_button?.isGone = true - // } - //} } catch (e: Exception) { logError(e) } } -} \ No newline at end of file + + @SuppressLint("SourceLockedOrientationActivity") + private fun toggleRotate() { + activity?.let { + toggleOrientationWithSensor(it) + } + } + + private fun PlayerCustomLayoutBinding.hideControlsNames() { + fun iterate(layout: LinearLayout) { + layout.children.forEach { + if (it is MaterialButton) { + it.textSize = 0f + it.iconPadding = 0 + it.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START + it.setPadding(0,0,0,0) + } else if (it is LinearLayout) { + iterate(it) + } + } + } + iterate(playerLockHolder.parent as LinearLayout) + } + + override fun playerDimensionsLoaded(width: Int, height: Int) { + isVerticalOrientation = height > width + updateOrientation() + } + + private fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + + playerBinding?.timeLeft?.text = formattedTime + } + } + + private fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + playerBinding?.exoDuration?.isInvisible = showRemaining + playerBinding?.timeLeft?.isVisible = showRemaining + } + + private fun dynamicOrientation(): Int { + return if (autoPlayerRotateEnabled) { + if (isVerticalOrientation) { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + } else { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE // default orientation + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 67f58195..d4fd047c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -6,6 +6,7 @@ import android.app.Dialog import android.content.Context import android.content.Intent import android.content.res.ColorStateList +import android.os.Build import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -13,29 +14,46 @@ import android.view.View import android.view.ViewGroup import android.widget.* import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog +import androidx.annotation.OptIn import androidx.core.animation.addListener import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager -import com.google.android.exoplayer2.Format.NO_VALUE -import com.google.android.exoplayer2.util.MimeTypes -import com.hippo.unifile.UniFile +import androidx.media3.common.Format.NO_VALUE +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding +import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding +import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.result.* -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -46,18 +64,11 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.dialog_online_subtitles.* -import kotlinx.android.synthetic.main.dialog_online_subtitles.apply_btt -import kotlinx.android.synthetic.main.dialog_online_subtitles.cancel_btt -import kotlinx.android.synthetic.main.fragment_player.* -import kotlinx.android.synthetic.main.player_custom_layout.* -import kotlinx.android.synthetic.main.player_select_source_and_subs.* -import kotlinx.android.synthetic.main.player_select_source_and_subs.subtitles_click_settings -import kotlinx.android.synthetic.main.player_select_tracks.* +import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job +import java.io.Serializable import java.util.* -import kotlin.collections.ArrayList -import kotlin.collections.HashMap +import kotlin.math.abs class GeneratorPlayer : FullScreenPlayer() { companion object { @@ -71,7 +82,10 @@ class GeneratorPlayer : FullScreenPlayer() { } val subsProviders - get() = subtitleProviders.filter { !it.requiresLogin || it.loginInfo() != null } + get() = subtitleProviders.filter { provider -> + (provider as? AbstractSubApi)?.let { !it.requiresLogin || it.loginInfo() != null } + ?: true + } val subsProvidersIsActive get() = subsProviders.isNotEmpty() } @@ -94,18 +108,43 @@ class GeneratorPlayer : FullScreenPlayer() { private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none + private var binding: FragmentPlayerBinding? = null + private fun startLoading() { player.release() currentSelectedSubtitles = null isActive = false - overlay_loading_skip_button?.isVisible = false - player_loading_overlay?.isVisible = true + binding?.overlayLoadingSkipButton?.isVisible = false + binding?.playerLoadingOverlay?.isVisible = true } - private fun setSubtitles(sub: SubtitleData?): Boolean { - currentSelectedSubtitles = sub - //Log.i(TAG, "setSubtitles = $sub") - return player.setPreferredSubtitles(sub) + private fun setSubtitles(subtitle: SubtitleData?): Boolean { + // If subtitle is changed -> Save the language + if (subtitle != currentSelectedSubtitles) { + val subtitleLanguage639 = if (subtitle == null) { + // "" is No Subtitles + "" + } else if (subtitle.languageCode != null) { + // Could be "English 4" which is why it is trimmed. + val trimmedLanguage = subtitle.languageCode.replace(Regex("\\d"), "").trim() + + languages.firstOrNull { language -> + language.languageName.equals(trimmedLanguage, ignoreCase = true) || + language.ISO_639_1 == subtitle.languageCode + }?.ISO_639_1 + } else { + null + } + + if (subtitleLanguage639 != null) { + setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguage639) + preferredAutoSelectSubtitles = subtitleLanguage639 + } + } + + currentSelectedSubtitles = subtitle + //Log.i(TAG, "setSubtitles = $subtitle") + return player.setPreferredSubtitles(subtitle) } override fun embeddedSubtitlesFetched(subtitles: List) { @@ -114,7 +153,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onTracksInfoChanged() { val tracks = player.getVideoTracks() - player_tracks_btt?.isVisible = + playerBinding?.playerTracksBtt?.isVisible = tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1 // Only set the preferred language if it is available. // Otherwise it may give some users audio track init failed! @@ -123,6 +162,13 @@ class GeneratorPlayer : FullScreenPlayer() { } } + override fun playerStatusChanged() { + super.playerStatusChanged() + if (player.getIsPlaying()) { + viewModel.forceClearCache = false + } + } + private fun noSubtitles(): Boolean { return setSubtitles(null) } @@ -136,7 +182,7 @@ class GeneratorPlayer : FullScreenPlayer() { return durPos.position } - var currentVerifyLink: Job? = null + private var currentVerifyLink: Job? = null private fun loadExtractorJob(extractorLink: ExtractorLink?) { currentVerifyLink?.cancel() @@ -154,12 +200,12 @@ class GeneratorPlayer : FullScreenPlayer() { if (link == null) return // manage UI - player_loading_overlay?.isVisible = false + binding?.playerLoadingOverlay?.isVisible = false uiReset() currentSelectedLink = link currentMeta = viewModel.getMeta() nextMeta = viewModel.getNextMeta() - setEpisodes(viewModel.getAllMeta() ?: emptyList()) + // setEpisodes(viewModel.getAllMeta() ?: emptyList()) isActive = true setPlayerDimen(null) setTitle() @@ -182,6 +228,7 @@ class GeneratorPlayer : FullScreenPlayer() { (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( currentSubs, settings = true, downloads = true ), + preview = isFullScreenPlayer ) } @@ -189,17 +236,31 @@ class GeneratorPlayer : FullScreenPlayer() { player.addTimeStamps(listOf()) // clear stamps } - private fun sortLinks(useQualitySettings: Boolean = true): List> { - return currentLinks.sortedBy { - val (linkData, _) = it - var quality = linkData?.quality ?: Qualities.Unknown.value + private fun closestQuality(target: Int?): Qualities { + if (target == null) return Qualities.Unknown + return Qualities.entries.minBy { abs(it.value - target) } + } - // we set all qualities above current max as reverse - if (useQualitySettings && quality > currentPrefQuality) { - quality = currentPrefQuality - quality - 1 - } - // negative because we want to sort highest quality first - -(quality) + private fun getLinkPriority( + qualityProfile: Int, + link: Pair + ): Int { + val (linkData, _) = link + + val qualityPriority = QualityDataHelper.getQualityPriority( + qualityProfile, + closestQuality(linkData?.quality) + ) + val sourcePriority = + QualityDataHelper.getSourcePriority(qualityProfile, linkData?.source) + + // negative because we want to sort highest quality first + return qualityPriority + sourcePriority + } + + private fun sortLinks(qualityProfile: Int): List> { + return currentLinks.sortedBy { + -getLinkPriority(qualityProfile, it) } } @@ -207,6 +268,7 @@ class GeneratorPlayer : FullScreenPlayer() { var episode: Int? = null, var season: Int? = null, var name: String? = null, + var imdbId: String? = null, ) private fun getMetaData(): TempMetaData { @@ -220,6 +282,7 @@ class GeneratorPlayer : FullScreenPlayer() { } meta.name = newMeta.headerName } + is ExtractorUri -> { if (newMeta.tvType?.isMovieType() == false) { meta.episode = newMeta.episode @@ -232,13 +295,15 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun openOnlineSubPicker( - context: Context, imdbId: Long?, dismissCallback: (() -> Unit) + context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { val providers = subsProviders val isSingleProvider = subsProviders.size == 1 val dialog = Dialog(context, R.style.AlertDialogCustomBlack) - dialog.setContentView(R.layout.dialog_online_subtitles) + val binding = + DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false) + dialog.setContentView(binding.root) var currentSubtitles: List = emptyList() var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null @@ -276,6 +341,7 @@ class GeneratorPlayer : FullScreenPlayer() { imageViewEnd.setImageDrawable(drawableEnd) } + @SuppressLint("SetTextI18n") override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) @@ -299,16 +365,14 @@ class GeneratorPlayer : FullScreenPlayer() { } dialog.show() - dialog.cancel_btt.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe() } - dialog.subtitle_adapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE - dialog.subtitle_adapter.adapter = arrayAdapter - val adapter = - dialog.subtitle_adapter.adapter as? ArrayAdapter + binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE + binding.subtitleAdapter.adapter = arrayAdapter - dialog.subtitle_adapter.setOnItemClickListener { _, _, position, _ -> + binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ -> currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } @@ -317,23 +381,24 @@ class GeneratorPlayer : FullScreenPlayer() { fun setSubtitlesList(list: List) { currentSubtitles = list - adapter?.clear() - adapter?.addAll(currentSubtitles) + arrayAdapter.clear() + arrayAdapter.addAll(currentSubtitles) } val currentTempMeta = getMetaData() + // bruh idk why it is not correct val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent)) - dialog.search_loading_bar.progressTintList = color - dialog.search_loading_bar.indeterminateTintList = color + binding.searchLoadingBar.progressTintList = color + binding.searchLoadingBar.indeterminateTintList = color observeNullable(viewModel.currentSubtitleYear) { // When year is changed search again - dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) - dialog.year_btt.text = it?.toString() ?: txt(R.string.none).asString(context) + binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) + binding.yearBtt.text = it?.toString() ?: txt(R.string.none).asString(context) } - dialog.year_btt?.setOnClickListener { + binding.yearBtt.setOnClickListener { val none = txt(R.string.none).asString(context) val currentYear = Calendar.getInstance().get(Calendar.YEAR) val earliestYear = 1900 @@ -361,15 +426,18 @@ class GeneratorPlayer : FullScreenPlayer() { ) } - dialog.subtitles_search.setOnQueryTextListener(object : + binding.subtitlesSearch.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { - dialog.search_loading_bar?.show() + binding.searchLoadingBar.show() ioSafe { val search = AbstractSubtitleEntities.SubtitleSearch( query = query ?: return@ioSafe, - imdb = imdbId, + imdbId = loadResponse?.getImdbId(), + tmdbId = loadResponse?.getTMDbId()?.toInt(), + malId = loadResponse?.getMalId()?.toInt(), + aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, lang = currentLanguageTwoLetters.ifBlank { null }, @@ -396,7 +464,7 @@ class GeneratorPlayer : FullScreenPlayer() { // ugly ik activity?.runOnUiThread { setSubtitlesList(items) - dialog.search_loading_bar?.hide() + binding.searchLoadingBar.hide() } } @@ -408,7 +476,7 @@ class GeneratorPlayer : FullScreenPlayer() { } }) - dialog.search_filter.setOnClickListener { view -> + binding.searchFilter.setOnClickListener { view -> val lang639_1 = languages.map { it.ISO_639_1 } activity?.showDialog(languages.map { it.languageName }, lang639_1.indexOf(currentLanguageTwoLetters), @@ -417,24 +485,29 @@ class GeneratorPlayer : FullScreenPlayer() { true, { }) { index -> currentLanguageTwoLetters = lang639_1[index] - dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) + binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } - dialog.apply_btt.setOnClickListener { + binding.applyBtt.setOnClickListener { currentSubtitle?.let { currentSubtitle -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> ioSafe { - val url = api.load(currentSubtitle) ?: return@ioSafe - val subtitle = SubtitleData( - name = getName(currentSubtitle, true), - url = url, - origin = SubtitleOrigin.URL, - mimeType = url.toSubtitleMimeType(), - headers = currentSubtitle.headers - ) - runOnMainThread { - addAndSelectSubtitles(subtitle) + val subtitles = + api.getResource(currentSubtitle).getSubtitles().map { resource -> + SubtitleData( + name = resource.name ?: getName(currentSubtitle, true), + url = resource.url, + origin = resource.origin, + mimeType = resource.url.toSubtitleMimeType(), + headers = currentSubtitle.headers, + currentSubtitle.lang + ) + } + if (subtitles.isNotEmpty()) { + runOnMainThread { + addAndSelectSubtitles(*subtitles.toTypedArray()) + } } } } @@ -447,11 +520,11 @@ class GeneratorPlayer : FullScreenPlayer() { } dialog.show() - dialog.subtitles_search.setQuery(currentTempMeta.name, true) + binding.subtitlesSearch.setQuery(currentTempMeta.name, true) //TODO: Set year text from currently loaded movie on Player //dialog.subtitles_search_year?.setText(currentTempMeta.year) } - + @OptIn(UnstableApi::class) private fun openSubPicker() { try { subsPathPicker.launch( @@ -472,7 +545,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun addAndSelectSubtitles(subtitleData: SubtitleData) { + private fun addAndSelectSubtitles( + vararg subtitleData: SubtitleData + ) { + if (subtitleData.isEmpty()) return + val selectedSubtitle = subtitleData.first() val ctx = context ?: return val subs = currentSubs + subtitleData @@ -484,14 +561,13 @@ class GeneratorPlayer : FullScreenPlayer() { player.saveData() player.reloadPlayer(ctx) - setSubtitles(subtitleData) - viewModel.addSubtitles(setOf(subtitleData)) + setSubtitles(selectedSubtitle) + viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() showToast( - activity, - String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name), + String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), Toast.LENGTH_LONG ) } @@ -504,22 +580,24 @@ class GeneratorPlayer : FullScreenPlayer() { if (uri == null) return@normalSafeApiCall val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall // RW perms for the path - val flags = + ctx.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) - ctx.contentResolver.takePersistableUriPermission(uri, flags) - - val file = UniFile.fromUri(ctx, uri) - println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}") + val file = SafeFile.fromUri(ctx, uri) + val fileName = file?.name() + println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName") // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES - val name = file.name ?: uri.toString() + val name = fileName ?: uri.toString() val subtitleData = SubtitleData( name, uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), - emptyMap() + emptyMap(), + null ) addAndSelectSubtitles(subtitleData) @@ -535,17 +613,19 @@ class GeneratorPlayer : FullScreenPlayer() { //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val isPlaying = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) val currentSubtitles = sortSubs(currentSubs) val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) - sourceDialog.setContentView(R.layout.player_select_source_and_subs) + val binding = + PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) + sourceDialog.setContentView(binding.root) selectSourceDialog = sourceDialog sourceDialog.show() - val providerList = sourceDialog.sort_providers - val subtitleList = sourceDialog.sort_subtitles + val providerList = binding.sortProviders + val subtitleList = binding.sortSubtitles val loadFromFileFooter: TextView = layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView @@ -566,6 +646,8 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { + val currentLoadResponse = viewModel.getLoadResponse() + val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView @@ -576,7 +658,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) - openOnlineSubPicker(it.context, null) { + openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } } @@ -585,33 +667,39 @@ class GeneratorPlayer : FullScreenPlayer() { var sourceIndex = 0 var startSource = 0 + var sortedUrls = emptyList>() - val sortedUrls = sortLinks(useQualitySettings = false) - if (sortedUrls.isEmpty()) { - sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true - } else { - startSource = sortedUrls.indexOf(currentSelectedLink) - sourceIndex = startSource + fun refreshLinks(qualityProfile: Int) { + sortedUrls = sortLinks(qualityProfile) + if (sortedUrls.isEmpty()) { + sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = + true + } else { + startSource = sortedUrls.indexOf(currentSelectedLink) + sourceIndex = startSource - val sourcesArrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + val sourcesArrayAdapter = + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - sourcesArrayAdapter.addAll(sortedUrls.map { (link, uri) -> - val name = link?.name ?: uri?.name ?: "NULL" - "$name ${Qualities.getStringByInt(link?.quality)}" - }) + sourcesArrayAdapter.addAll(sortedUrls.map { (link, uri) -> + val name = link?.name ?: uri?.name ?: "NULL" + "$name ${Qualities.getStringByInt(link?.quality)}" + }) - providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE - providerList.adapter = sourcesArrayAdapter - providerList.setSelection(sourceIndex) - providerList.setItemChecked(sourceIndex, true) + providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE + providerList.adapter = sourcesArrayAdapter + providerList.setSelection(sourceIndex) + providerList.setItemChecked(sourceIndex, true) - providerList.setOnItemClickListener { _, _, which, _ -> - sourceIndex = which - providerList.setItemChecked(which, true) + providerList.setOnItemClickListener { _, _, which, _ -> + sourceIndex = which + providerList.setItemChecked(which, true) + } } } + refreshLinks(currentQualityProfile) + sourceDialog.setOnDismissListener { if (shouldDismiss) dismiss() selectSourceDialog = null @@ -647,11 +735,34 @@ class GeneratorPlayer : FullScreenPlayer() { } } - sourceDialog.cancel_btt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) } - sourceDialog.subtitles_encoding_format?.apply { + fun setProfileName(profile: Int) { + binding.sourceSettingsBtt.setText( + QualityDataHelper.getProfileName( + profile + ) + ) + } + setProfileName(currentQualityProfile) + + binding.profilesClickSettings.setOnClickListener { + val activity = activity ?: return@setOnClickListener + QualityProfileDialog( + activity, + R.style.AlertDialogCustomBlack, + currentLinks.mapNotNull { it.first }, + currentQualityProfile + ) { profile -> + currentQualityProfile = profile.id + setProfileName(profile.id) + refreshLinks(profile.id) + }.show() + } + + binding.subtitlesEncodingFormat.apply { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -664,7 +775,7 @@ class GeneratorPlayer : FullScreenPlayer() { text = prefNames[if (index == -1) 0 else index] } - sourceDialog.subtitles_click_settings?.setOnClickListener { + binding.subtitlesClickSettings.setOnClickListener { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -686,14 +797,13 @@ class GeneratorPlayer : FullScreenPlayer() { settingsManager.edit().putString( ctx.getString(R.string.subtitles_encoding_key), prefValues[it] ).apply() - updateForcedEncoding(ctx) dismiss() player.seekTime(-1) // to update subtitles, a dirty trick } } - sourceDialog.apply_btt?.setOnClickListener { + binding.applyBtt.setOnClickListener { var init = false if (sourceIndex != startSource) { init = true @@ -733,20 +843,19 @@ class GeneratorPlayer : FullScreenPlayer() { it.height?.times(-1) } val currentAudioTracks = tracks.allAudioTracks - - val trackBuilder = AlertDialog.Builder(ctx, R.style.AlertDialogCustomBlack) - .setView(R.layout.player_select_tracks) - - val tracksDialog = trackBuilder.create() + val binding: PlayerSelectTracksBinding = + PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) + val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) + trackDialog.setContentView(binding.root) + trackDialog.show() // selectTracksDialog = tracksDialog - tracksDialog.show() - val videosList = tracksDialog.video_tracks_list - val audioList = tracksDialog.auto_tracks_list + val videosList = binding.videoTracksList + val audioList = binding.autoTracksList - tracksDialog.video_tracks_holder.isVisible = currentVideoTracks.size > 1 - tracksDialog.audio_tracks_holder.isVisible = currentAudioTracks.size > 1 + binding.videoTracksHolder.isVisible = currentVideoTracks.size > 1 + binding.audioTracksHolder.isVisible = currentAudioTracks.size > 1 fun dismiss() { if (isPlaying) { @@ -781,7 +890,7 @@ class GeneratorPlayer : FullScreenPlayer() { videosList.setItemChecked(which, true) } - tracksDialog.setOnDismissListener { + trackDialog.setOnDismissListener { dismiss() // selectTracksDialog = null } @@ -811,11 +920,11 @@ class GeneratorPlayer : FullScreenPlayer() { audioList.setItemChecked(which, true) } - tracksDialog.cancel_btt?.setOnClickListener { - tracksDialog.dismissSafe(activity) + binding.cancelBtt.setOnClickListener { + trackDialog.dismissSafe(activity) } - tracksDialog.apply_btt?.setOnClickListener { + binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( currentTrack?.language, currentTrack?.id @@ -828,7 +937,7 @@ class GeneratorPlayer : FullScreenPlayer() { player.setMaxVideoSize(width, height, currentVideo?.id) } - tracksDialog.dismissSafe(activity) + trackDialog.dismissSafe(activity) } } } catch (e: Exception) { @@ -837,20 +946,25 @@ class GeneratorPlayer : FullScreenPlayer() { } - override fun playerError(exception: Exception) { + override fun playerError(exception: Throwable) { Log.i(TAG, "playerError = $currentSelectedLink") + if (!hasNextMirror()) { + viewModel.forceClearCache = true + } super.playerError(exception) } private fun noLinksFound() { - showToast(activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT) + viewModel.forceClearCache = true + + showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) activity?.popCurrentPage() } private fun startPlayer() { if (isActive) return // we don't want double load when you skip loading - val links = sortLinks() + val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -871,12 +985,12 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun hasNextMirror(): Boolean { - val links = sortLinks() + val links = sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = sortLinks() + val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -899,14 +1013,13 @@ class GeneratorPlayer : FullScreenPlayer() { var maxEpisodeSet: Int? = null var hasRequestedStamps: Boolean = false - override fun playerPositionChanged(posDur: Pair) { + override fun playerPositionChanged(position: Long, duration: Long) { // Don't save livestream data if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return // Don't save NSFW data if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return - val (position, duration) = posDur if (duration <= 0L) return // idk how you achieved this, but div by zero crash if (!hasRequestedStamps) { hasRequestedStamps = true @@ -935,6 +1048,7 @@ class GeneratorPlayer : FullScreenPlayer() { is ResultEpisode -> { DataStoreHelper.removeLastWatched(newMeta.parentId) } + is ExtractorUri -> { DataStoreHelper.removeLastWatched(newMeta.parentId) } @@ -951,6 +1065,7 @@ class GeneratorPlayer : FullScreenPlayer() { isFromDownload = false ) } + is ExtractorUri -> { DataStoreHelper.setLastWatched( resumeMeta.parentId, @@ -975,15 +1090,24 @@ class GeneratorPlayer : FullScreenPlayer() { ctx.getString(R.string.episode_sync_enabled_key), true ) ) maxEpisodeSet = meta.episode - sync.modifyMaxEpisode(meta.episode) + sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) } } if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE } } - player_skip_op?.isVisible = isOpVisible - player_skip_episode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true + + playerBinding?.playerSkipOp?.isVisible = isOpVisible + + when { + isLayout(PHONE) -> + playerBinding?.playerSkipEpisode?.isVisible = + !isOpVisible && viewModel.hasNextEpisode() == true + + else -> + playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true + } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { viewModel.preLoadNextLinks() @@ -1082,6 +1206,7 @@ class GeneratorPlayer : FullScreenPlayer() { season = meta.season tvType = meta.tvType } + is ExtractorUri -> { headerName = meta.headerName subName = meta.name @@ -1119,7 +1244,7 @@ class GeneratorPlayer : FullScreenPlayer() { //Hide title, if set in setting if (limitTitle < 0) { - player_video_title?.visibility = View.GONE + playerBinding?.playerVideoTitle?.visibility = View.GONE } else { //Truncate video title if it exceeds limit val differenceInLength = playerVideoTitle.length - limitTitle @@ -1130,40 +1255,43 @@ class GeneratorPlayer : FullScreenPlayer() { } val isFiller: Boolean? = (currentMeta as? ResultEpisode)?.isFiller - player_episode_filler_holder?.isVisible = isFiller ?: false - player_video_title?.text = playerVideoTitle + playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false + playerBinding?.playerVideoTitle?.text = playerVideoTitle } @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { val extra = if (widthHeight != null) { val (width, height) = widthHeight - "${width}x${height}" + "- ${width}x${height}" } else { "" } val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" - val title = when (titleRez) { + val title = when (titleRez) { 0 -> "" 1 -> extra 2 -> source - 3 -> "$source - $extra" + 3 -> "$source $extra" else -> "" } - player_video_title_rez?.text = title - player_video_title_rez?.isVisible = title.isNotBlank() + playerBinding?.playerVideoTitleRez?.apply { + text = title + isVisible = title.isNotBlank() + } } - override fun playerDimensionsLoaded(widthHeight: Pair) { - setPlayerDimen(widthHeight) + override fun playerDimensionsLoaded(width: Int, height: Int) { + super.playerDimensionsLoaded(width, height) + setPlayerDimen(width to height) } private fun unwrapBundle(savedInstanceState: Bundle?) { Log.i(TAG, "unwrapBundle = $savedInstanceState") savedInstanceState?.let { bundle -> - sync.addSyncs(bundle.getSerializable("syncData") as? HashMap?) + sync.addSyncs(bundle.getSafeSerializable>("syncData")) } } @@ -1171,8 +1299,8 @@ class GeneratorPlayer : FullScreenPlayer() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason - isTv = isTvSettings() - layout = if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player + layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java] @@ -1181,7 +1309,14 @@ class GeneratorPlayer : FullScreenPlayer() { unwrapBundle(savedInstanceState) unwrapBundle(arguments) - return super.onCreateView(inflater, container, savedInstanceState) + val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null + binding = FragmentPlayerBinding.bind(root) + return root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() } var timestampShowState = false @@ -1192,9 +1327,8 @@ class GeneratorPlayer : FullScreenPlayer() { private fun displayTimeStamp(show: Boolean) { if (timestampShowState == show) return skipIndex++ - println("displayTimeStamp = $show") timestampShowState = show - skip_chapter_button?.apply { + playerBinding?.skipChapterButton?.apply { val showWidth = 170.toPx val noShowWidth = 10.toPx //if((show && width == showWidth) || (!show && width == noShowWidth)) { @@ -1214,7 +1348,18 @@ class GeneratorPlayer : FullScreenPlayer() { from, to ).apply { addListener(onEnd = { - if (!show) skip_chapter_button?.isVisible = false + if (show) { + if (!isShowing) { + // Automatically request focus if the menu is not opened + playerBinding?.skipChapterButton?.requestFocus() + } + } else { + playerBinding?.skipChapterButton?.isVisible = false + if (!isShowing) { + // Automatically return focus to play pause + playerBinding?.playerPausePlay?.requestFocus() + } + } }) addUpdateListener { valueAnimator -> val value = valueAnimator.animatedValue as Int @@ -1234,10 +1379,10 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { if (timestamp != null) { - skip_chapter_button.setText(timestamp.uiText) + playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) val currentIndex = skipIndex - skip_chapter_button?.handler?.postDelayed({ + playerBinding?.skipChapterButton?.handler?.postDelayed({ if (skipIndex == currentIndex) displayTimeStamp(false) }, 6000) @@ -1280,11 +1425,12 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadLinks() } - overlay_loading_skip_button?.setOnClickListener { + binding?.overlayLoadingSkipButton?.setOnClickListener { startPlayer() } - player_loading_go_back?.setOnClickListener { + binding?.playerLoadingGoBack?.setOnClickListener { + exitFullscreen() player.release() activity?.popCurrentPage() } @@ -1298,6 +1444,7 @@ class GeneratorPlayer : FullScreenPlayer() { is Resource.Loading -> { startLoading() } + is Resource.Success -> { // provider returned false //if (it.value != true) { @@ -1305,8 +1452,9 @@ class GeneratorPlayer : FullScreenPlayer() { //} startPlayer() } + is Resource.Failure -> { - showToast(activity, it.errorString, Toast.LENGTH_LONG) + showToast(it.errorString, Toast.LENGTH_LONG) startPlayer() } } @@ -1314,11 +1462,22 @@ class GeneratorPlayer : FullScreenPlayer() { observe(viewModel.currentLinks) { currentLinks = it - val turnVisible = it.isNotEmpty() - val wasGone = overlay_loading_skip_button?.isGone == true - overlay_loading_skip_button?.isVisible = turnVisible + val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true + val wasGone = binding?.overlayLoadingSkipButton?.isGone == true + binding?.overlayLoadingSkipButton?.isVisible = turnVisible + + normalSafeApiCall { + if (currentLinks.any { link -> + getLinkPriority(currentQualityProfile, link) >= + QualityDataHelper.AUTO_SKIP_PRIORITY + } + ) { + startPlayer() + } + } + if (turnVisible && wasGone) { - overlay_loading_skip_button?.requestFocus() + binding?.overlayLoadingSkipButton?.requestFocus() } } @@ -1348,4 +1507,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } } -} \ No newline at end of file +} + +@Suppress("DEPRECATION") +inline fun Bundle.getSafeSerializable(key: String) : T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable(key, T::class.java) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt index a1287e6a..6b8e6ea8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -1,10 +1,51 @@ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +enum class LoadType { + Unknown, + InApp, + InAppDownload, + ExternalApp, + Browser, + Chromecast, + Fcast +} + +fun LoadType.toSet() : Set { + return when(this) { + LoadType.InApp -> setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + LoadType.Browser -> setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + LoadType.InAppDownload -> setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.M3U8 + ) + LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.entries.toSet() + LoadType.Chromecast -> setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + LoadType.Fcast -> setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + } +} interface IGenerator { val hasCache: Boolean + val canSkipLoading: Boolean fun hasNext(): Boolean fun hasPrev(): Boolean @@ -13,15 +54,15 @@ interface IGenerator { fun goto(index: Int) fun getCurrentId(): Int? // this is used to save data or read data about this id - fun getCurrent(offset : Int = 0): Any? // this is used to get metadata about the current playing, can return null - fun getAll() : List? // this us used to get the metadata about all entries, not needed + fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null + fun getAll(): List? // this us used to get the metadata about all entries, not needed /* not safe, must use try catch */ suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + type: LoadType, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset : Int = 0, + offset: Int = 0, ): Boolean } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index ba5a4a85..89c6f73b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -1,13 +1,13 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context +import android.graphics.Bitmap +import android.util.Rational import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri enum class PlayerEventType(val value: Int) { - //Stop(-1), Pause(0), Play(1), SeekForward(2), @@ -25,6 +25,7 @@ enum class PlayerEventType(val value: Int) { Resize(13), SearchSubtitlesOnline(14), SkipOp(15), + Restart(16), } enum class CSPlayerEvent(val value: Int) { @@ -38,15 +39,127 @@ enum class CSPlayerEvent(val value: Int) { PrevEpisode(6), PlayPauseToggle(7), ToggleMute(8), + Restart(9), } enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, - //IsDone, } +enum class PlayerEventSource { + /** This event was invoked from the user pressing some button or selecting something */ + UI, + + /** This event was invoked automatically */ + Player, + + /** This event was invoked from a external sync tool like WatchTogether */ + Sync, +} + +abstract class PlayerEvent { + abstract val source: PlayerEventSource +} + +/** this is used to update UI based of the current time, + * using requestedListeningPercentages as well as saving time */ +data class PositionEvent( + override val source: PlayerEventSource, + val fromMs: Long, + val toMs: Long, + /** duration of the entire video */ + val durationMs: Long, +) : PlayerEvent() { + /** how many ms (+-) we have skipped */ + val seekMs : Long get() = toMs - fromMs +} + +/** player error when rendering or misc, used to display toast or log */ +data class ErrorEvent( + val error: Throwable, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event when timestamps appear, null when it should disappear */ +data class TimestampInvokedEvent( + val timestamp: EpisodeSkip.SkipStamp, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ +data class TimestampSkippedEvent( + val timestamp: EpisodeSkip.SkipStamp, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** this is used by the player to load the next or prev episode */ +data class EpisodeSeekEvent( + /** -1 = prev, 1 = next, will never be 0, atm the user cant seek more than +-1 */ + val offset: Int, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() { + init { + assert(offset != 0) + } +} + +/** Event when the video is resized aka changed resolution or mirror */ +data class ResizedEvent( + val height: Int, + val width: Int, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event when the player status update, along with the previous status (for animation)*/ +data class StatusEvent( + val wasPlaying: CSPlayerLoading, + val isPlaying: CSPlayerLoading, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event when tracks are changed, used for UI changes */ +data class TracksChangedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event from player to give all embedded subtitles */ +data class EmbeddedSubtitlesFetchedEvent( + val tracks: List, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** on attach player to view */ +data class PlayerAttachedEvent( + val player: Any?, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event from player to inform that subtitles have updated in some way */ +data class SubtitlesUpdatedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** current player starts, asking for all other programs to shut the fuck up */ +data class RequestAudioFocusEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Pause event, separate from StatusEvent */ +data class PauseEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Play event, separate from StatusEvent */ +data class PlayEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event when the player video has ended, up to the settings on what to do when that happens */ +data class VideoEndedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() interface Track { /** @@ -86,17 +199,8 @@ data class CurrentTracks( class InvalidFileException(msg: String) : Exception(msg) //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 -const val STATE_RESUME_WINDOW = "resumeWindow" -const val STATE_RESUME_POSITION = "resumePosition" -const val STATE_PLAYER_FULLSCREEN = "playerFullscreen" -const val STATE_PLAYER_PLAYING = "playerOnPlay" const val ACTION_MEDIA_CONTROL = "media_control" const val EXTRA_CONTROL_TYPE = "control_type" -const val PLAYBACK_SPEED = "playback_speed" -const val RESIZE_MODE_KEY = "resize_mode" // Last used resize mode -const val PLAYBACK_SPEED_KEY = "playback_speed" // Last used playback speed -const val PREFERRED_SUBS_KEY = "preferred_subtitles" // Last used resize mode -//const val PLAYBACK_FASTFORWARD = "playback_fastforward" // Last used resize mode /** Abstract Exoplayer logic, can be expanded to other players */ interface IPlayer { @@ -107,27 +211,16 @@ interface IPlayer { fun getDuration(): Long? fun getPosition(): Long? - fun seekTime(time: Long) - fun seekTo(time: Long) + fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI) + fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI) fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms fun initCallbacks( - playerUpdated: (Any?) -> Unit, // attach player to view - updateIsPlaying: ((Pair) -> Unit)? = null, // (wasPlaying, isPlaying) - requestAutoFocus: (() -> Unit)? = null, // current player starts, asking for all other programs to shut the fuck up - playerError: ((Exception) -> Unit)? = null, // player error when rendering or misc, used to display toast or log - playerDimensionsLoaded: ((Pair) -> Unit)? = null, // (with, height), for UI - requestedListeningPercentages: List? = null, // this is used to request when the player should report back view percentage - playerPositionChanged: ((Pair) -> Unit)? = null,// (position, duration) this is used to update UI based of the current time - nextEpisode: (() -> Unit)? = null, // this is used by the player to load the next episode - prevEpisode: (() -> Unit)? = null, // this is used by the player to load the previous episode - subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way - embeddedSubtitlesFetched: ((List) -> Unit)? = null, // callback from player to give all embedded subtitles - onTracksInfoChanged: (() -> Unit)? = null, // Callback when tracks are changed, used for UI changes - onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null, // Callback when timestamps appear, null when it should disappear - onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null, // callback for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) + eventHandler: ((PlayerEvent) -> Unit), + /** this is used to request when the player should report back view percentage */ + requestedListeningPercentages: List? = null, ) fun releaseCallbacks() @@ -145,16 +238,20 @@ interface IPlayer { startPosition: Long? = null, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean? = true + autoPlay: Boolean? = true, + preview : Boolean = true, ) fun reloadPlayer(context: Context) + fun getPreview(fraction : Float) : Bitmap? + fun hasPreview() : Boolean + fun setActiveSubtitles(subtitles: Set) fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun getCurrentPreferredSubtitle(): SubtitleData? - fun handleEvent(event: CSPlayerEvent) + fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI) fun onStop() fun onPause() @@ -167,6 +264,19 @@ interface IPlayer { fun getVideoTracks(): CurrentTracks + /** + * Original video aspect ratio used for PiP mode + * + * Set using: Width, Height. + * Example: Rational(16, 9) + * + * If null will default to set no aspect ratio. + * + * PiP functions calling this needs to coerce this value between 0.418410 and 2.390000 + * to prevent crashes. + */ + fun getAspectRatio(): Rational? + /** If no parameters are set it'll default to no set size, Specifying the id allows for track overrides to force the player to pick the quality. */ fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt index 1f242481..20feae41 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt @@ -1,17 +1,45 @@ package com.lagradost.cloudstream3.ui.player +import android.net.Uri +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.utils.* -import java.net.URI +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.unshortenLinkSafe +data class ExtractorUri( + val uri: Uri, + val name: String, + + val basePath: String? = null, + val relativePath: String? = null, + val displayName: String? = null, + + val id: Int? = null, + val parentId: Int? = null, + val episode: Int? = null, + val season: Int? = null, + val headerName: String? = null, + val tvType: TvType? = null, +) + +/** + * Used to open the player more easily with the LinkGenerator + **/ +data class BasicLink( + val url: String, + val name: String? = null, +) class LinkGenerator( - private val links: List, + private val links: List, private val extract: Boolean = true, private val referer: String? = null, private val isM3u8: Boolean? = null ) : IGenerator { override val hasCache = false + override val canSkipLoading = true override fun getCurrentId(): Int? { return null @@ -41,13 +69,13 @@ class LinkGenerator( override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + type: LoadType, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int ): Boolean { links.amap { link -> - if (!extract || !loadExtractor(link, referer, { + if (!extract || !loadExtractor(link.url, referer, { subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it)) }) { callback(it to null) @@ -57,12 +85,11 @@ class LinkGenerator( callback( ExtractorLink( "", - link, - unshortenLinkSafe(link), // unshorten because it might be a raw link + link.name ?: link.url, + unshortenLinkSafe(link.url), // unshorten because it might be a raw link referer ?: "", - Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall { - URI(link).path?.substringAfterLast(".")?.contains("m3u") - } ?: false + Qualities.Unknown.value, + type = INFER_TYPE, ) to null ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java b/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java index 3b47b27a..232440cc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java @@ -15,10 +15,10 @@ */ package com.lagradost.cloudstream3.ui.player; -import static com.google.android.exoplayer2.text.Cue.DIMEN_UNSET; -import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_NUMBER; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Assertions.checkState; +import static androidx.media3.common.text.Cue.DIMEN_UNSET; +import static androidx.media3.common.text.Cue.LINE_TYPE_NUMBER; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; import static java.lang.annotation.ElementType.TYPE_USE; import android.os.Handler; @@ -27,26 +27,28 @@ import android.os.Looper; import android.os.Message; import androidx.annotation.IntDef; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.BaseRenderer; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.FormatHolder; -import com.google.android.exoplayer2.RendererCapabilities; -import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; -import com.google.android.exoplayer2.text.Cue; -import com.google.android.exoplayer2.text.CueGroup; -import com.google.android.exoplayer2.text.Subtitle; -import com.google.android.exoplayer2.text.SubtitleDecoder; -import com.google.android.exoplayer2.text.SubtitleDecoderException; -import com.google.android.exoplayer2.text.SubtitleDecoderFactory; -import com.google.android.exoplayer2.text.SubtitleInputBuffer; -import com.google.android.exoplayer2.text.SubtitleOutputBuffer; -import com.google.android.exoplayer2.text.TextOutput; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.MimeTypes; -import com.google.android.exoplayer2.util.Util; +import androidx.annotation.OptIn; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.text.Cue; +import androidx.media3.common.text.CueGroup; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.BaseRenderer; +import androidx.media3.exoplayer.FormatHolder; +import androidx.media3.exoplayer.RendererCapabilities; +import androidx.media3.exoplayer.source.SampleStream; +import androidx.media3.exoplayer.text.SubtitleDecoderFactory; +import androidx.media3.exoplayer.text.TextOutput; +import androidx.media3.extractor.text.Subtitle; +import androidx.media3.extractor.text.SubtitleDecoder; +import androidx.media3.extractor.text.SubtitleDecoderException; +import androidx.media3.extractor.text.SubtitleInputBuffer; +import androidx.media3.extractor.text.SubtitleOutputBuffer; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -65,6 +67,7 @@ import java.util.stream.Collectors; * obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s * is delegated to a {@link TextOutput}. */ +@OptIn(markerClass = UnstableApi.class) public class NonFinalTextRenderer extends BaseRenderer implements Callback { private static final String TAG = "TextRenderer"; @@ -72,7 +75,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { /** * @param trackType The track type that the renderer handles. One of the {@link C} {@code * TRACK_TYPE_*} constants. - * @param outputHandler + * @param outputHandler todo description */ public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) { super(trackType); @@ -164,13 +167,14 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { finalStreamEndPositionUs = C.TIME_UNSET; } + @NonNull @Override public String getName() { return TAG; } @Override - public @Capabilities int supportsFormat(Format format) { + public @Capabilities int supportsFormat(@NonNull Format format) { if (decoderFactory.supportsFormat(format)) { return RendererCapabilities.create( format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM); @@ -310,7 +314,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { return; } // Try and read the next subtitle from the source. - @ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0); + @SampleStream.ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0); if (result == C.RESULT_BUFFER_READ) { if (nextInputBuffer.isEndOfStream()) { inputStreamEnded = true; @@ -413,13 +417,11 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { @SuppressWarnings("unchecked") @Override public boolean handleMessage(Message msg) { - switch (msg.what) { - case MSG_UPDATE_OUTPUT: - invokeUpdateOutputInternal((List) msg.obj); - return true; - default: - throw new IllegalStateException(); + if (msg.what == MSG_UPDATE_OUTPUT) { + invokeUpdateOutputInternal((List) msg.obj); + return true; } + throw new IllegalStateException(); } private void invokeUpdateOutputInternal(List cues) { @@ -438,7 +440,6 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { } ).collect(Collectors.toList()); - output.onCues(fixedCues); output.onCues(new CueGroup(fixedCues, 0L)); } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt new file mode 100644 index 00000000..f00f8a61 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.player + +import android.app.Activity +import android.content.ContentUris +import android.net.Uri +import androidx.core.content.ContextCompat.getString +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.safefile.SafeFile + +object OfflinePlaybackHelper { + fun playLink(activity: Activity, url: String) { + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + LinkGenerator( + listOf( + BasicLink(url) + ) + ) + ) + ) + } + + fun playUri(activity: Activity, uri: Uri) { + val name = SafeFile.fromUri(activity, uri)?.name() + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + DownloadFileGenerator( + listOf( + ExtractorUri( + uri = uri, + name = name ?: getString(activity, R.string.downloaded_file), + // well not the same as a normal id, but we take it as users may want to + // play downloaded files and save the location + id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()?.hashCode() + ) + ) + ) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt deleted file mode 100644 index cfe27a30..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.widget.ContentLoadingProgressBar -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.ui.result.getDisplayPosition -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.player_episodes_large.view.episode_holder_large -import kotlinx.android.synthetic.main.player_episodes_large.view.episode_progress -import kotlinx.android.synthetic.main.player_episodes_small.view.episode_holder -import kotlinx.android.synthetic.main.result_episode_large.view.* - - -data class PlayerEpisodeClickEvent(val action: Int, val data: Any) - -class PlayerEpisodeAdapter( - private val items: MutableList = mutableListOf(), - private val clickCallback: (PlayerEpisodeClickEvent) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return PlayerEpisodeCardViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.player_episodes, parent, false), - clickCallback, - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - println("HOLDER $holder $position") - - when (holder) { - is PlayerEpisodeCardViewHolder -> { - holder.bind(items[position]) - } - } - } - - override fun getItemCount(): Int { - return items.size - } - - fun updateList(newList: List) { - println("Updated list $newList") - val diffResult = DiffUtil.calculateDiff(EpisodeDiffCallback(this.items, newList)) - items.clear() - items.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - class PlayerEpisodeCardViewHolder - constructor( - itemView: View, - private val clickCallback: (PlayerEpisodeClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView) { - @SuppressLint("SetTextI18n") - fun bind(card: Any) { - if (card is ResultEpisode) { - val (parentView, otherView) = if (card.poster == null) { - itemView.episode_holder to itemView.episode_holder_large - } else { - itemView.episode_holder_large to itemView.episode_holder - } - - val episodeText: TextView? = parentView.episode_text - val episodeFiller: MaterialButton? = parentView.episode_filler - val episodeRating: TextView? = parentView.episode_rating - val episodeDescript: TextView? = parentView.episode_descript - val episodeProgress: ContentLoadingProgressBar? = parentView.episode_progress - val episodePoster: ImageView? = parentView.episode_poster - - parentView.isVisible = true - otherView.isVisible = false - - - episodeText?.apply { - val name = - if (card.name == null) "${context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" - - text = name - isSelected = true - } - - episodeFiller?.isVisible = card.isFiller == true - - val displayPos = card.getDisplayPosition() - episodeProgress?.max = (card.duration / 1000).toInt() - episodeProgress?.progress = (displayPos / 1000).toInt() - episodeProgress?.isVisible = displayPos > 0L - episodePoster?.isVisible = episodePoster?.setImage(card.poster) == true - - if (card.rating != null) { - episodeRating?.text = episodeRating?.context?.getString(R.string.rated_format) - ?.format(card.rating.toFloat() / 10f) - } else { - episodeRating?.text = "" - } - - episodeRating?.isGone = episodeRating?.text.isNullOrBlank() - - episodeDescript?.apply { - text = card.description.html() - isGone = text.isNullOrBlank() - //setOnClickListener { - // clickCallback.invoke(PlayerEpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) - //} - } - - parentView.setOnClickListener { - clickCallback.invoke(PlayerEpisodeClickEvent(0, card)) - } - - if (isTrueTvSettings()) { - parentView.isFocusable = true - parentView.isFocusableInTouchMode = true - parentView.touchscreenBlocksFocus = false - } - } - } - } -} - -class EpisodeDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val a = oldList[oldItemPosition] - val b = newList[newItemPosition] - return if (a is ResultEpisode && b is ResultEpisode) { - a.id == b.id - } else { - a == b - } - } - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index 7faf0cf5..122eaa97 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -5,20 +5,22 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri import kotlinx.coroutines.Job +import kotlinx.coroutines.launch class PlayerGeneratorViewModel : ViewModel() { companion object { - val TAG = "PlayViewGen" + const val TAG = "PlayViewGen" } private var generator: IGenerator? = null @@ -38,6 +40,13 @@ class PlayerGeneratorViewModel : ViewModel() { private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear + /** + * Save the Episode ID to prevent starting multiple link loading Jobs when preloading links. + */ + private var currentLoadingEpisodeId: Int? = null + + var forceClearCache = false + fun setSubtitleYear(year: Int?) { _currentSubtitleYear.postValue(year) } @@ -72,22 +81,39 @@ class PlayerGeneratorViewModel : ViewModel() { } fun preLoadNextLinks() { + val id = getId() + // Do not preload if already loading + if (id == currentLoadingEpisodeId) return + Log.i(TAG, "preLoadNextLinks") currentJob?.cancel() - currentJob = viewModelScope.launchSafe { - if (generator?.hasCache == true && generator?.hasNext() == true) { - safeApiCall { - generator?.generateLinks( - clearCache = false, - isCasting = false, - {}, - {}, - offset = 1 - ) + currentLoadingEpisodeId = id + + currentJob = viewModelScope.launch { + try { + if (generator?.hasCache == true && generator?.hasNext() == true) { + safeApiCall { + generator?.generateLinks( + type = LoadType.InApp, + clearCache = false, + callback = {}, + subtitleCallback = {}, + offset = 1 + ) + } + } + } catch (t: Throwable) { + logError(t) + } finally { + if (currentLoadingEpisodeId == id) { + currentLoadingEpisodeId = null } } } } + fun getLoadResponse(): LoadResponse? { + return normalSafeApiCall { (generator as? RepoLinkGenerator?)?.page } + } fun getMeta(): Any? { return normalSafeApiCall { generator?.getCurrent() } @@ -147,7 +173,7 @@ class PlayerGeneratorViewModel : ViewModel() { } } - fun loadLinks(clearCache: Boolean = false, isCasting: Boolean = false) { + fun loadLinks(type: LoadType = LoadType.InApp) { Log.i(TAG, "loadLinks") currentJob?.cancel() @@ -156,18 +182,24 @@ class PlayerGeneratorViewModel : ViewModel() { val currentSubs = mutableSetOf() // clear old data - _currentSubs.postValue(currentSubs) - _currentLinks.postValue(currentLinks) + _currentSubs.postValue(emptySet()) + _currentLinks.postValue(emptySet()) // load more data _loadingLinks.postValue(Resource.Loading()) val loadingState = safeApiCall { - generator?.generateLinks(clearCache = clearCache, isCasting = isCasting, { + generator?.generateLinks(type = type, clearCache = forceClearCache, callback = { currentLinks.add(it) - _currentLinks.postValue(currentLinks) - }, { + // Clone to prevent ConcurrentModificationException + normalSafeApiCall { + // Extra normalSafeApiCall since .toSet() iterates. + _currentLinks.postValue(currentLinks.toSet()) + } + }, subtitleCallback = { currentSubs.add(it) - // _currentSubs.postValue(currentSubs) // this causes ConcurrentModificationException, so fuck it + normalSafeApiCall { + _currentSubs.postValue(currentSubs.toSet()) + } }) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index 0fbc22f6..93857234 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt @@ -7,28 +7,23 @@ import android.app.RemoteAction import android.content.Intent import android.graphics.drawable.Icon import android.os.Build +import android.util.Rational import androidx.annotation.RequiresApi import androidx.annotation.StringRes import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import kotlin.math.roundToInt class PlayerPipHelper { companion object { + @RequiresApi(Build.VERSION_CODES.O) private fun getPen(activity: Activity, code: Int): PendingIntent { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getBroadcast( - activity, - code, - Intent("media_control").putExtra("control_type", code), - PendingIntent.FLAG_IMMUTABLE - ) - } else { - PendingIntent.getBroadcast( - activity, - code, - Intent("media_control").putExtra("control_type", code), - 0 - ) - } + return PendingIntent.getBroadcast( + activity, + code, + Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), + PendingIntent.FLAG_IMMUTABLE + ) } @RequiresApi(Build.VERSION_CODES.O) @@ -48,7 +43,7 @@ class PlayerPipHelper { } @RequiresApi(Build.VERSION_CODES.O) - fun updatePIPModeActions(activity: Activity, isPlaying: Boolean) { + fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) { val actions: ArrayList = ArrayList() actions.add( getRemoteAction( @@ -87,9 +82,32 @@ class PlayerPipHelper { CSPlayerEvent.SeekForward ) ) - activity.setPictureInPictureParams( - PictureInPictureParams.Builder().setActions(actions).build() - ) + + // Nessecary to prevent crashing. + val mixAspectRatio = 0.41841f // ~1/2.39 + val maxAspectRatio = 2.39f // widescreen standard + val ratioAccuracy = 100000 // To convert the float to int + + // java.lang.IllegalArgumentException: setPictureInPictureParams: Aspect ratio is too extreme (must be between 0.418410 and 2.390000) + val fixedRational = + aspectRatio?.toFloat()?.coerceIn(mixAspectRatio, maxAspectRatio)?.let { + Rational((it * ratioAccuracy).roundToInt(), ratioAccuracy) + } + + normalSafeApiCall { + activity.setPictureInPictureParams( + PictureInPictureParams.Builder() + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setSeamlessResizeEnabled(true) + setAutoEnterEnabled(isPlaying) + } + } + .setAspectRatio(fixedRational) + .setActions(actions) + .build() + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt index 8d85f176..02a7ee03 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -4,8 +4,10 @@ import android.util.Log import android.util.TypedValue import android.view.ViewGroup import android.widget.FrameLayout -import com.google.android.exoplayer2.ui.SubtitleView -import com.google.android.exoplayer2.util.MimeTypes +import androidx.annotation.OptIn +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveBloat import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveCaptions @@ -30,13 +32,15 @@ enum class SubtitleOrigin { * @param name To be displayed in the player * @param url Url for the subtitle, when EMBEDDED_IN_VIDEO this variable is used as the real backend id * @param headers if empty it will use the base onlineDataSource headers else only the specified headers + * @param languageCode Not guaranteed to follow any standard. Could be something like "English 4" or "en". * */ data class SubtitleData( val name: String, val url: String, val origin: SubtitleOrigin, val mimeType: String, - val headers: Map + val headers: Map, + val languageCode: String? ) { /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { @@ -45,6 +49,7 @@ data class SubtitleData( } } +@OptIn(UnstableApi::class) class PlayerSubtitleHelper { private var activeSubtitles: Set = emptySet() private var allSubtitles: Set = emptySet() @@ -80,7 +85,8 @@ class PlayerSubtitleHelper { url = subtitleFile.url, origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), - headers = emptyMap() + headers = emptyMap(), + languageCode = subtitleFile.lang ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt new file mode 100644 index 00000000..ae800dbd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -0,0 +1,547 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.WorkerThread +import androidx.core.graphics.scale +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.M3u8Helper2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.log2 + +const val MAX_LOD = 6 +const val MIN_LOD = 3 + +data class ImageParams( + val width: Int, + val height: Int, +) { + companion object { + val DEFAULT = ImageParams(200, 320) + fun new16by9(width: Int): ImageParams { + if (width < 100) { + return DEFAULT + } + return ImageParams( + width / 4, + (width * 9) / (4 * 16) + ) + } + } + + init { + assert(width > 0 && height > 0) + } +} + +interface IPreviewGenerator { + fun hasPreview(): Boolean + fun getPreviewImage(fraction: Float): Bitmap? + fun release() + + var params: ImageParams + + var durationMs: Long + var loadedImages: Int + + companion object { + fun new(): IPreviewGenerator { + val userDisabled = AcraApplication.context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( + ctx.getString(R.string.preview_seekbar_key), true) == false + } ?: false + /** because TV has low ram + not show we disable this for now */ + return if (isLayout(TV) || userDisabled) { + empty() + } else { + PreviewGenerator() + } + } + + fun empty(): IPreviewGenerator { + return NoPreviewGenerator() + } + } +} + +private fun rescale(image: Bitmap, params: ImageParams): Bitmap { + if (image.width <= params.width && image.height <= params.height) return image + val new = image.scale(params.width, params.height) + // throw away the old image + if (new != image) { + image.recycle() + } + return new +} + +/** rescale to not take up as much memory */ +private fun MediaMetadataRetriever.image(timeUs: Long, params: ImageParams): Bitmap? { + /*if (timeUs <= 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + val primary = this.primaryImage + if (primary != null) { + return rescale(primary, params) + } + } catch (t: Throwable) { + logError(t) + } + }*/ + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + this.getScaledFrameAtTime( + timeUs, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + params.width, + params.height + ) + } else { + return rescale(this.getFrameAtTime(timeUs) ?: return null, params) + } +} + +/** PreviewGenerator that hides the implementation details of the sub generators that is used, used for source switch cache */ +class PreviewGenerator : IPreviewGenerator { + + /** the most up to date generator, will always mirror the actual source in the player */ + private var currentGenerator: IPreviewGenerator = NoPreviewGenerator() + + /** the longest generated preview of the same episode */ + private var lastGenerator: IPreviewGenerator = NoPreviewGenerator() + + /** always NoPreviewGenerator, used as a cache for nothing */ + private val dummy: IPreviewGenerator = NoPreviewGenerator() + + /** if the current generator is the same as the last by checking time */ + private fun isSameLength(): Boolean = + currentGenerator.durationMs.minus(lastGenerator.durationMs).absoluteValue < 10_000L + + /** use the backup if the current generator is init or if they have the same length */ + private val backupGenerator: IPreviewGenerator + get() { + if (currentGenerator.durationMs == 0L || isSameLength()) { + return lastGenerator + } + return dummy + } + + override fun hasPreview(): Boolean { + return currentGenerator.hasPreview() || backupGenerator.hasPreview() + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + return try { + currentGenerator.getPreviewImage(fraction) ?: backupGenerator.getPreviewImage(fraction) + } catch (t: Throwable) { + logError(t) + null + } + } + + override fun release() { + lastGenerator.release() + currentGenerator.release() + lastGenerator = NoPreviewGenerator() + currentGenerator = NoPreviewGenerator() + } + + override var params: ImageParams = ImageParams.DEFAULT + set(value) { + field = value + lastGenerator.params = value + backupGenerator.params = value + currentGenerator.params = value + } + + override var durationMs: Long + get() = currentGenerator.durationMs + set(_) {} + override var loadedImages: Int + get() = currentGenerator.loadedImages + set(_) {} + + fun clear(keepCache: Boolean) { + if (keepCache) { + if (!isSameLength() || currentGenerator.loadedImages >= lastGenerator.loadedImages || lastGenerator.durationMs == 0L) { + // the current generator is better than the last generator, therefore keep the current + // or the lengths are not the same, therefore favoring the more recent selection + + // if they are the same we favor the current generator + lastGenerator.release() + lastGenerator = currentGenerator + } else { + // otherwise just keep the last generator and throw away the current generator + currentGenerator.release() + } + } else { + // we switched the episode, therefore keep nothing + lastGenerator.release() + lastGenerator = NoPreviewGenerator() + currentGenerator.release() + // we assume that we set currentGenerator right after this, so currentGenerator != NoPreviewGenerator + } + } + + fun load(link: ExtractorLink, keepCache: Boolean) { + clear(keepCache) + + when (link.type) { + ExtractorLinkType.M3U8 -> { + currentGenerator = M3u8PreviewGenerator(params).apply { + load(url = link.url, headers = link.getAllHeaders()) + } + } + + ExtractorLinkType.VIDEO -> { + currentGenerator = Mp4PreviewGenerator(params).apply { + load(url = link.url, headers = link.getAllHeaders()) + } + } + + else -> { + Log.i("PreviewImg", "unsupported format for $link") + } + } + } + + fun load(context: Context, link: ExtractorUri, keepCache: Boolean) { + clear(keepCache) + currentGenerator = Mp4PreviewGenerator(params).apply { + load(keepCache = keepCache, context = context, uri = link.uri) + } + } +} + +@Suppress("UNUSED_PARAMETER") +private class NoPreviewGenerator : IPreviewGenerator { + override fun hasPreview(): Boolean = false + override fun getPreviewImage(fraction: Float): Bitmap? = null + override fun release() = Unit + override var params: ImageParams + get() = ImageParams(0, 0) + set(value) {} + override var durationMs: Long = 0L + override var loadedImages: Int = 0 +} + +private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { + // generated images 1:1 to idx of hsl + private var images: Array = arrayOf() + + companion object { + private const val TAG = "PreviewImgM3u8" + } + + + + // prefixSum[i] = sum(hsl.ts[0..i].time) + // where [0] = 0, [1] = hsl.ts[0].time aka time at start of segment, do [b] - [a] for range a,b + private var prefixSum: Array = arrayOf() + + // how many images has been generated + override var loadedImages: Int = 0 + + // how many images we can generate in total, == hsl.size ?: 0 + private var totalImages: Int = 0 + + override fun hasPreview(): Boolean { + return totalImages > 0 && loadedImages >= minOf(totalImages, 4) + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + var bestIdx = -1 + var bestDiff = Double.MAX_VALUE + synchronized(images) { + // just find the best one in a for loop, we don't care about bin searching rn + for (i in 0..images.size) { + val diff = prefixSum[i].minus(fraction).absoluteValue + if (diff > bestDiff) { + break + } + if (images[i] != null) { + bestIdx = i + bestDiff = diff + } + } + return images.getOrNull(bestIdx) + } + /* + val targetIndex = prefixSum.binarySearch(target) + var ret = images[targetIndex] + if (ret != null) { + return ret + } + for (i in 0..images.size) { + ret = images.getOrNull(i+targetIndex) ?: + }*/ + } + + private fun clear() { + synchronized(images) { + currentJob?.cancel() + // for (i in images.indices) { + // images[i]?.recycle() + // } + images = arrayOf() + prefixSum = arrayOf() + loadedImages = 0 + totalImages = 0 + } + } + + override fun release() { + clear() + images = arrayOf() + } + + override var durationMs: Long = 0L + + private var currentJob: Job? = null + fun load(url: String, headers: Map) { + clear() + currentJob?.cancel() + currentJob = ioSafe { + withContext(Dispatchers.IO) { + Log.i(TAG, "Loading with url = $url headers = $headers") + //tmpFile = + // File.createTempFile("video", ".ts", context.cacheDir).apply { + // deleteOnExit() + // } + val retriever = MediaMetadataRetriever() + val hsl = M3u8Helper2.hslLazy( + listOf( + M3u8Helper.M3u8Stream( + streamUrl = url, + headers = headers + ) + ), + selectBest = false + ) + + // no support for encryption atm + if (hsl.isEncrypted) { + Log.i(TAG, "m3u8 is encrypted") + totalImages = 0 + return@withContext + } + + // total duration of the entire m3u8 in seconds + val duration = hsl.allTsLinks.sumOf { it.time ?: 0.0 } + durationMs = (duration * 1000.0).toLong() + val durationInv = 1.0 / duration + + // if the total duration is less then 10s then something is very wrong or + // too short playback to matter + if (duration <= 10.0) { + totalImages = 0 + return@withContext + } + + totalImages = hsl.allTsLinks.size + + // we cant init directly as it is no guarantee of in order + prefixSum = Array(hsl.allTsLinks.size + 1) { 0.0 } + var runningSum = 0.0 + for (i in hsl.allTsLinks.indices) { + runningSum += (hsl.allTsLinks[i].time ?: 0.0) + prefixSum[i + 1] = runningSum * durationInv + } + synchronized(images) { + images = Array(hsl.size) { null } + loadedImages = 0 + } + + val maxLod = ceil(log2(duration)).toInt().coerceIn(MIN_LOD, MAX_LOD) + val count = hsl.allTsLinks.size + for (l in 1..maxLod) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val index = (count.div(1 shl l) + (i * count) / items).coerceIn(0, hsl.size) + if (synchronized(images) { images[index] } != null) { + continue + } + Log.i(TAG, "Generating preview for $index") + + val ts = hsl.allTsLinks[index] + try { + retriever.setDataSource(ts.url, hsl.headers) + if (!isActive) { + return@withContext + } + val img = retriever.image(0, params) + if (!isActive) { + return@withContext + } + if (img == null || img.width <= 1 || img.height <= 1) continue + synchronized(images) { + images[index] = img + loadedImages += 1 + } + } catch (t: Throwable) { + logError(t) + continue + } + } + } + + } + } + } +} + +private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { + // lod = level of detail where the number indicates how many ones there is + // 2^(lod-1) = images + private var loadedLod = 0 + override var loadedImages = 0 + private var images = Array((1 shl MAX_LOD) - 1) { + null + } + + companion object { + private const val TAG = "PreviewImgMp4" + } + + override fun hasPreview(): Boolean { + synchronized(images) { + return loadedLod >= MIN_LOD + } + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + synchronized(images) { + if (loadedLod < MIN_LOD) { + Log.i(TAG, "Requesting preview for $fraction but $loadedLod < $MIN_LOD") + return null + } + Log.i(TAG, "Requesting preview for $fraction") + + var bestIdx = 0 + var bestDiff = 0.5f.minus(fraction).absoluteValue + + // this should be done mathematically, but for now we just loop all images + for (l in 1..loadedLod + 1) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val idx = items - 1 + i + if (idx > loadedImages) { + break + } + if (images[idx] == null) { + continue + } + val currentFraction = + (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) + val diff = currentFraction.minus(fraction).absoluteValue + if (diff < bestDiff) { + bestDiff = diff + bestIdx = idx + } + } + } + Log.i(TAG, "Best diff found at ${bestDiff * 100}% diff (${bestIdx})") + return images[bestIdx] + } + } + + // also check out https://github.com/wseemann/FFmpegMediaMetadataRetriever + private val retriever: MediaMetadataRetriever = MediaMetadataRetriever() + + private fun clear(keepCache: Boolean) { + if (keepCache) return + synchronized(images) { + loadedLod = 0 + loadedImages = 0 + // for (i in images.indices) { + // images[i]?.recycle() + // images[i] = null + //} + images.fill(null) + } + } + + private var currentJob: Job? = null + fun load(url: String, headers: Map) { + currentJob?.cancel() + currentJob = ioSafe { + Log.i(TAG, "Loading with url = $url headers = $headers") + clear(true) + retriever.setDataSource(url, headers) + start(this) + } + } + + fun load(keepCache: Boolean, context: Context, uri: Uri) { + currentJob?.cancel() + currentJob = ioSafe { + Log.i(TAG, "Loading with uri = $uri") + clear(keepCache) + retriever.setDataSource(context, uri) + start(this) + } + } + + override fun release() { + currentJob?.cancel() + clear(false) + } + + override var durationMs: Long = 0L + + @Throws + @WorkerThread + private fun start(scope: CoroutineScope) { + Log.i(TAG, "Started loading preview") + + val durationMs = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() + ?: throw IllegalArgumentException("Bad video duration") + this.durationMs = durationMs + val durationUs = (durationMs * 1000L).toFloat() + //val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: throw IllegalArgumentException("Bad video width") + //val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: throw IllegalArgumentException("Bad video height") + + // log2 # 10s durations in the video ~= how many segments we have + val maxLod = ceil(log2((durationMs / 10_000).toFloat())).toInt().coerceIn(MIN_LOD, MAX_LOD) + + for (l in 1..maxLod) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val idx = items - 1 + i // as sum(prev) = cur-1 + // frame = 100 / 2^lod + i * 100 / 2^(lod-1) = duration % where lod is one indexed + val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) + Log.i(TAG, "Generating preview for ${fraction * 100}%") + val frame = durationUs * fraction + val img = retriever.image(frame.toLong(), params) + if (!scope.isActive) return + if (img == null || img.width <= 1 || img.height <= 1) continue + synchronized(images) { + images[idx] = img + loadedImages = maxOf(loadedImages, idx) + } + } + + synchronized(images) { + loadedLod = maxOf(loadedLod, l) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt index 2ce53ea5..588afbb5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt @@ -1,15 +1,22 @@ package com.lagradost.cloudstream3.ui.player import android.util.Log +import androidx.media3.common.util.UnstableApi import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri import kotlin.math.max import kotlin.math.min +data class Cache( + val linkCache: MutableSet, + val subtitleCache: MutableSet, + var lastCachedTimestamp: Long = unixTime +) + class RepoLinkGenerator( private val episodes: List, private var currentIndex: Int = 0, @@ -17,11 +24,12 @@ class RepoLinkGenerator( ) : IGenerator { companion object { const val TAG = "RepoLink" - val cache: HashMap, Pair, MutableSet>> = + val cache: HashMap, Cache> = hashMapOf() } override val hasCache = true + override val canSkipLoading = true override fun hasNext(): Boolean { return currentIndex < episodes.size - 1 @@ -67,18 +75,19 @@ class RepoLinkGenerator( override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + type: LoadType, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int, + offset: Int ): Boolean { + val allowedTypes = type.toSet() val index = currentIndex val current = episodes.getOrNull(index + offset) ?: return false - val (currentLinkCache, currentSubsCache) = if (clearCache) { - Pair(mutableSetOf(), mutableSetOf()) + val (currentLinkCache, currentSubsCache, lastCachedTimestamp) = if (clearCache) { + Cache(mutableSetOf(), mutableSetOf(), unixTime) } else { - cache[Pair(current.apiName, current.id)] ?: Pair(mutableSetOf(), mutableSetOf()) + cache[current.apiName to current.id] ?: Cache(mutableSetOf(), mutableSetOf(), unixTime) } //val currentLinkCache = if (clearCache) mutableSetOf() else linkCache[index].toMutableSet() @@ -88,9 +97,15 @@ class RepoLinkGenerator( val currentSubsUrls = mutableSetOf() // makes all subs urls unique val currentSubsNames = mutableSetOf() // makes all subs names unique - currentLinkCache.forEach { link -> + val invalidateCache = unixTime - lastCachedTimestamp > 60 * 20 // 20 minutes + if(invalidateCache){ + currentLinkCache.clear() + currentSubsCache.clear() + } + + currentLinkCache.filter { allowedTypes.contains(it.type) }.forEach { link -> currentLinks.add(link.url) - callback(Pair(link, null)) + callback(link to null) } currentSubsCache.forEach { sub -> @@ -108,10 +123,10 @@ class RepoLinkGenerator( val result = APIRepository( getApiFromNameNull(current.apiName) ?: throw Exception("This provider does not exist") ).loadLinks(current.data, - isCasting, - { file -> + isCasting = LoadType.Chromecast == type, + subtitleCallback = { file -> val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (!currentSubsUrls.contains(correctFile.url)) { + if (correctFile.url.isNotEmpty() && !currentSubsUrls.contains(correctFile.url)) { currentSubsUrls.add(correctFile.url) // this part makes sure that all names are unique for UX @@ -132,19 +147,21 @@ class RepoLinkGenerator( } } }, - { link -> + callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (!currentLinks.contains(link.url)) { - if (!currentLinkCache.contains(link)) { - currentLinks.add(link.url) + if (link.url.isNotEmpty() && !currentLinks.contains(link.url) && !currentLinkCache.contains(link)) { + currentLinks.add(link.url) + + if (allowedTypes.contains(link.type)) { callback(Pair(link, null)) - currentLinkCache.add(link) - //linkCache[index] = currentLinkCache } + + currentLinkCache.add(link) + // linkCache[index] = currentLinkCache } } ) - cache[Pair(current.apiName, current.id)] = Pair(currentLinkCache, currentSubsCache) + cache[Pair(current.apiName, current.id)] = Cache(currentLinkCache, currentSubsCache, unixTime) return result } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt new file mode 100644 index 00000000..ce457740 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt @@ -0,0 +1,52 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding +import com.lagradost.cloudstream3.utils.AppContextUtils + +data class SourcePriority( + val data: T, + val name: String, + var priority: Int +) + +class PriorityAdapter(override val items: MutableList>) : + AppContextUtils.DiffAdapter>(items) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return PriorityViewHolder( + PlayerPrioritizeItemBinding.inflate(LayoutInflater.from(parent.context),parent,false), + ) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is PriorityViewHolder -> holder.bind(items[position]) + } + } + + class PriorityViewHolder( + val binding: PlayerPrioritizeItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: SourcePriority) { + binding.priorityText.text = item.name + + fun updatePriority() { + binding.priorityNumber.text = item.priority.toString() + } + + updatePriority() + binding.addButton.setOnClickListener { + // If someone clicks til the integer limit then they deserve to crash. + item.priority++ + updatePriority() + } + + binding.subtractButton.setOnClickListener { + item.priority-- + updatePriority() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt new file mode 100644 index 00000000..45f6aa66 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt @@ -0,0 +1,109 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding +import com.lagradost.cloudstream3.ui.result.UiImage +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.UIHelper.setImage + +class ProfilesAdapter( + override val items: MutableList, + val usedProfile: Int, + val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, +) : + AppContextUtils.DiffAdapter( + items, + comparison = { first: QualityDataHelper.QualityProfile, second: QualityDataHelper.QualityProfile -> + first.id == second.id + }) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ProfilesViewHolder( + PlayerQualityProfileItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) + ) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ProfilesViewHolder -> holder.bind(items[position], position) + } + } + + private var currentItem: Pair? = null + + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return currentItem?.second + } + + inner class ProfilesViewHolder( + val binding: PlayerQualityProfileItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + private val art = listOf( + R.drawable.profile_bg_teal, + R.drawable.profile_bg_blue, + R.drawable.profile_bg_dark_blue, + R.drawable.profile_bg_purple, + R.drawable.profile_bg_pink, + R.drawable.profile_bg_red, + R.drawable.profile_bg_orange, + ) + + fun bind(item: QualityDataHelper.QualityProfile, index: Int) { + val priorityText: TextView = binding.profileText + val profileBg: ImageView = binding.profileImageBackground + val wifiText: TextView = binding.textIsWifi + val dataText: TextView = binding.textIsMobileData + val outline: View = binding.outline + val cardView: View = binding.cardView + + priorityText.text = item.name.asString(itemView.context) + dataText.isVisible = item.type == QualityDataHelper.QualityProfileType.Data + wifiText.isVisible = item.type == QualityDataHelper.QualityProfileType.WiFi + + fun setCurrentItem() { + val prevIndex = currentItem?.first + // Prevent UI bug when re-selecting the item quickly + if (prevIndex == index) { + return + } + currentItem = index to item + clickCallback.invoke(prevIndex, index) + } + + outline.isVisible = currentItem?.second?.id == item.id + + profileBg.setImage(UiImage.Drawable(art[index % art.size]), null, false) { palette -> + val color = palette.getDarkVibrantColor( + ContextCompat.getColor( + itemView.context, + R.color.dubColorBg + ) + ) + wifiText.backgroundTintList = ColorStateList.valueOf(color) + dataText.backgroundTintList = ColorStateList.valueOf(color) + } + + val textStyle = + if (item.id == usedProfile) { + Typeface.BOLD + } else { + Typeface.NORMAL + } + + priorityText.setTypeface(null, textStyle) + + cardView.setOnClickListener { + setCurrentItem() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt new file mode 100644 index 00000000..3267efd7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt @@ -0,0 +1,158 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import androidx.annotation.StringRes +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.Qualities + +object QualityDataHelper { + private const val VIDEO_SOURCE_PRIORITY = "video_source_priority" + private const val VIDEO_PROFILE_NAME = "video_profile_name" + private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority" + private const val VIDEO_PROFILE_TYPE = "video_profile_type" + private const val DEFAULT_SOURCE_PRIORITY = 1 + /** + * Automatically skip loading links once this priority is reached + **/ + const val AUTO_SKIP_PRIORITY = 10 + + /** + * Must be higher than amount of QualityProfileTypes + **/ + private const val PROFILE_COUNT = 7 + + /** + * Unique guarantees that there will always be one of this type in the profile list. + **/ + enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) { + None(R.string.none, false), + WiFi(R.string.wifi, true), + Data(R.string.mobile_data, true) + } + + data class QualityProfile( + val name: UiText, + val id: Int, + val type: QualityProfileType + ) + + fun getSourcePriority(profile: Int, name: String?): Int { + if (name == null) return DEFAULT_SOURCE_PRIORITY + return getKey( + "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", + name, + DEFAULT_SOURCE_PRIORITY + ) ?: DEFAULT_SOURCE_PRIORITY + } + + fun setSourcePriority(profile: Int, name: String, priority: Int) { + setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) + } + + fun setProfileName(profile: Int, name: String?) { + val path = "$currentAccount/$VIDEO_PROFILE_NAME/$profile" + if (name == null) { + removeKey(path) + } else { + setKey(path, name.trim()) + } + } + + fun getProfileName(profile: Int): UiText { + return getKey("$currentAccount/$VIDEO_PROFILE_NAME/$profile")?.let { txt(it) } + ?: txt(R.string.profile_number, profile) + } + + fun getQualityPriority(profile: Int, quality: Qualities): Int { + return getKey( + "$currentAccount/$VIDEO_QUALITY_PRIORITY/$profile", + quality.value.toString(), + quality.defaultPriority + ) ?: quality.defaultPriority + } + + fun setQualityPriority(profile: Int, quality: Qualities, priority: Int) { + setKey( + "$currentAccount/$VIDEO_QUALITY_PRIORITY/$profile", + quality.value.toString(), + priority + ) + } + + fun getQualityProfileType(profile: Int): QualityProfileType { + return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None + } + + fun setQualityProfileType(profile: Int, type: QualityProfileType?) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" + if (type == QualityProfileType.None) { + removeKey(path) + } else { + setKey(path, type) + } + } + + /** + * Gets all quality profiles, always includes one profile with WiFi and Data + * Must under all circumstances at least return one profile + **/ + fun getProfiles(): List { + val availableTypes = QualityProfileType.entries.toMutableList() + val profiles = (1..PROFILE_COUNT).map { profileNumber -> + // Get the real type + val type = getQualityProfileType(profileNumber) + + // This makes it impossible to get more than one of each type + // Duplicates will be turned to None + val uniqueType = if (type.unique && !availableTypes.remove(type)) { + QualityProfileType.None + } else { + type + } + + QualityProfile( + getProfileName(profileNumber), + profileNumber, + uniqueType + ) + }.toMutableList() + + /** + * If no profile of this type exists: insert it on the earliest profile with None type + **/ + fun insertType( + list: MutableList, + type: QualityProfileType + ) { + if (list.any { it.type == type }) return + val index = + list.indexOfFirst { it.type == QualityProfileType.None } + list.getOrNull(index)?.copy(type = type) + ?.let { fixed -> + list.set(index, fixed) + } + } + + QualityProfileType.entries.forEach { + if (it.unique) insertType(profiles, it) + } + + debugAssert({ + !QualityProfileType.entries.all { type -> + !type.unique || profiles.any { it.type == type } + } + }, { "All unique quality types do not exist" }) + + debugAssert({ + profiles.isEmpty() + }, { "No profiles!" }) + + return profiles + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt new file mode 100644 index 00000000..0537092c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.app.Dialog +import androidx.annotation.StyleRes +import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe + +class QualityProfileDialog( + val activity: FragmentActivity, + @StyleRes val themeRes: Int, + private val links: List, + private val usedProfile: Int, + private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit +) : Dialog(activity, themeRes) { + override fun show() { + + val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) + + setContentView(binding.root)//R.layout.player_quality_profile_dialog) + /*val profilesRecyclerView: RecyclerView = profiles_recyclerview + val useBtt: View = use_btt + val editBtt: View = edit_btt + val cancelBtt: View = cancel_btt + val defaultBtt: View = set_default_btt + val currentProfileText: TextView = currently_selected_profile_text + val selectedItemActionsHolder: View = selected_item_holder*/ + binding.apply { + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() + } + + fun refreshProfiles() { + currentlySelectedProfileText.text = getProfileName(usedProfile).asString(context) + (profilesRecyclerview.adapter as? ProfilesAdapter)?.updateList(getProfiles()) + } + + profilesRecyclerview.adapter = ProfilesAdapter( + mutableListOf(), + usedProfile, + ) { oldIndex: Int?, newIndex: Int -> + profilesRecyclerview.adapter?.notifyItemChanged(newIndex) + selectedItemHolder.alpha = 1f + if (oldIndex != null) { + profilesRecyclerview.adapter?.notifyItemChanged(oldIndex) + } + } + + refreshProfiles() + + editBtt.setOnClickListener { + getCurrentProfile()?.let { profile -> + SourcePriorityDialog(context, themeRes, links, profile) { + refreshProfiles() + }.show() + } + } + + + setDefaultBtt.setOnClickListener { + val currentProfile = getCurrentProfile() ?: return@setOnClickListener + val choices = QualityDataHelper.QualityProfileType.entries + .filter { it != QualityDataHelper.QualityProfileType.None } + val choiceNames = choices.map { txt(it.stringRes).asString(context) } + + activity.showBottomDialog( + choiceNames, + choices.indexOf(currentProfile.type), + txt(R.string.set_default).asString(context), + false, + {}, + { index -> + val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog + // Remove previous picks + if (pickedChoice.unique) { + getProfiles().filter { it.type == pickedChoice }.forEach { + QualityDataHelper.setQualityProfileType(it.id, null) + } + } + + QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice) + refreshProfiles() + }) + } + + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } + + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback.invoke(it) + this@QualityProfileDialog.dismissSafe() + } + } + } + super.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt new file mode 100644 index 00000000..bc6282af --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt @@ -0,0 +1,100 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.app.Dialog +import android.content.Context +import android.view.LayoutInflater +import androidx.annotation.StyleRes +import androidx.appcompat.app.AlertDialog +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe + +class SourcePriorityDialog( + val ctx: Context, + @StyleRes themeRes: Int, + val links: List, + private val profile: QualityDataHelper.QualityProfile, + /** + * Notify that the profile overview should be updated, for example if the name has been updated + * Should not be called excessively. + **/ + private val updatedCallback: () -> Unit +) : Dialog(ctx, themeRes) { + override fun show() { + val binding = PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) + setContentView(binding.root) + val sourcesRecyclerView = binding.sortSources + val qualitiesRecyclerView = binding.sortQualities + val profileText = binding.profileTextEditable + val saveBtt = binding.saveBtt + val exitBtt = binding.closeBtt + val helpBtt = binding.helpBtt + + profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) + profileText.hint = txt(R.string.profile_number, profile.id).asString(context) + + sourcesRecyclerView.adapter = PriorityAdapter( + links.map { link -> + SourcePriority( + null, + link.source, + QualityDataHelper.getSourcePriority(profile.id, link.source) + ) + }.distinctBy { it.name }.sortedBy { -it.priority }.toMutableList() + ) + + qualitiesRecyclerView.adapter = PriorityAdapter( + Qualities.entries.mapNotNull { + SourcePriority( + it, + Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, + QualityDataHelper.getQualityPriority(profile.id, it) + ) + }.sortedBy { -it.priority }.toMutableList() + ) + + @Suppress("UNCHECKED_CAST") // We know the types + saveBtt.setOnClickListener { + val qualityAdapter = qualitiesRecyclerView.adapter as? PriorityAdapter + val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter + + val qualities = qualityAdapter?.items ?: emptyList() + val sources = sourcesAdapter?.items ?: emptyList() + + qualities.forEach { + val data = it.data as? Qualities ?: return@forEach + QualityDataHelper.setQualityPriority(profile.id, data, it.priority) + } + + sources.forEach { + QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) + } + + qualityAdapter?.updateList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.updateList(sources.sortedBy { -it.priority }) + + val savedProfileName = profileText.text.toString() + if (savedProfileName.isBlank()) { + QualityDataHelper.setProfileName(profile.id, null) + } else { + QualityDataHelper.setProfileName(profile.id, savedProfileName) + } + updatedCallback.invoke() + } + + exitBtt.setOnClickListener { + this.dismissSafe() + } + + helpBtt.setOnClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { + setMessage(R.string.quality_profile_help) + }.show() + } + + super.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt index ba57d2de..12adc040 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.res.Configuration import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -16,11 +17,11 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.QuickSearchBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe @@ -31,13 +32,18 @@ import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchViewModel -import com.lagradost.cloudstream3.utils.AppUtils.ownShow +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality +import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.quick_search.* import java.util.concurrent.locks.ReentrantLock class QuickSearchFragment : Fragment() { @@ -45,6 +51,13 @@ class QuickSearchFragment : Fragment() { const val AUTOSEARCH_KEY = "autosearch" const val PROVIDER_KEY = "providers" + fun pushSearch( + autoSearch: String? = null, + providers: Array? = null + ) { + pushSearch(activity, autoSearch, providers) + } + fun pushSearch( activity: Activity?, autoSearch: String? = null, @@ -72,6 +85,8 @@ class QuickSearchFragment : Fragment() { private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel + var binding: QuickSearchBinding? = null + private var bottomSheetDialog: BottomSheetDialog? = null @@ -79,13 +94,21 @@ class QuickSearchFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] bottomSheetDialog?.ownShow() - return inflater.inflate(R.layout.quick_search, container, false) + val localBinding = QuickSearchBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.quick_search, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() } override fun onDestroy() { @@ -111,7 +134,7 @@ class QuickSearchFragment : Fragment() { activity?.getSpanCount()?.let { HomeFragment.currentSpan = it } - quick_search_autofit_results.spanCount = HomeFragment.currentSpan + binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan HomeFragment.currentSpan = HomeFragment.currentSpan HomeFragment.configEvent.invoke(HomeFragment.currentSpan) } @@ -123,7 +146,7 @@ class QuickSearchFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(quick_search_root) + fixPaddingStatusbar(binding?.quickSearchRoot) fixGrid() arguments?.getStringArray(PROVIDER_KEY)?.let { @@ -136,23 +159,25 @@ class QuickSearchFragment : Fragment() { } else false if (isSingleProvider) { - quick_search_autofit_results.adapter = activity?.let { - SearchAdapter( + binding?.quickSearchAutofitResults?.apply { + adapter = SearchAdapter( ArrayList(), - quick_search_autofit_results, + this, ) { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + SearchHelper.handleSearchClickCallback(callback) } } + try { - quick_search?.queryHint = getString(R.string.search_hint_site).format(providers?.first()) + binding?.quickSearch?.queryHint = + getString(R.string.search_hint_site).format(providers?.first()) } catch (e: Exception) { logError(e) } } else { - quick_search_master_recycler?.adapter = - ParentItemAdapter(mutableListOf(), { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + binding?.quickSearchMasterRecycler?.adapter = + ParentItemAdapter(fragment = this, id = "quickSearchMasterRecycler".hashCode(), { callback -> + SearchHelper.handleSearchClickCallback(callback) //when (callback.action) { //SEARCH_ACTION_LOAD -> { // clickCallback?.invoke(callback) @@ -164,18 +189,17 @@ class QuickSearchFragment : Fragment() { bottomSheetDialog = null }) }) - quick_search_master_recycler?.layoutManager = GridLayoutManager(context, 1) + binding?.quickSearchMasterRecycler?.layoutManager = GridLayoutManager(context, 1) } - - quick_search_autofit_results?.isVisible = isSingleProvider - quick_search_master_recycler?.isGone = isSingleProvider + binding?.quickSearchAutofitResults?.isVisible = isSingleProvider + binding?.quickSearchMasterRecycler?.isGone = isSingleProvider val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (quick_search_master_recycler?.adapter as ParentItemAdapter?)?.apply { + (binding?.quickSearchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { updateList(list.map { ongoing -> val ongoingList = HomePageList( ongoing.apiName, @@ -192,19 +216,24 @@ class QuickSearchFragment : Fragment() { } val searchExitIcon = - quick_search?.findViewById(androidx.appcompat.R.id.search_close_btn) + binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) //val searchMagIcon = - // quick_search?.findViewById(androidx.appcompat.R.id.search_mag_icon) + // binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) - //searchMagIcon?.scaleX = 0.65f - //searchMagIcon?.scaleY = 0.65f + // searchMagIcon?.scaleX = 0.65f + // searchMagIcon?.scaleY = 0.65f + // Set the color for the search exit icon to the correct theme text color + val searchExitIconColor = TypedValue() - quick_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) + + binding?.quickSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - UIHelper.hideKeyboard(quick_search) + UIHelper.hideKeyboard(binding?.quickSearch) return true } @@ -214,27 +243,28 @@ class QuickSearchFragment : Fragment() { return true } }) - - quick_search_loading_bar.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - (quick_search_autofit_results?.adapter as? SearchAdapter)?.updateList( + (binding?.quickSearchAutofitResults?.adapter as? SearchAdapter)?.updateList( context?.filterSearchResultByFilmQuality(data) ?: data ) } searchExitIcon?.alpha = 1f - quick_search_loading_bar?.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f } + is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - quick_search_loading_bar?.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f } + is Resource.Loading -> { searchExitIcon?.alpha = 0f - quick_search_loading_bar?.alpha = 1f + binding?.quickSearchLoadingBar?.alpha = 1f } } } @@ -246,13 +276,21 @@ class QuickSearchFragment : Fragment() { // UIHelper.showInputMethod(view.findFocus()) // } //} + if (isLayout(PHONE or EMULATOR)) { + binding?.quickSearchBack?.apply { + isVisible = true + setOnClickListener { + activity?.popCurrentPage() + } + } + } - quick_search_back.setOnClickListener { - activity?.popCurrentPage() + if (isLayout(TV)) { + binding?.quickSearch?.requestFocus() } arguments?.getString(AUTOSEARCH_KEY)?.let { - quick_search?.setQuery(it, true) + binding?.quickSearch?.setQuery(it, true) arguments?.remove(AUTOSEARCH_KEY) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt index 92cecc37..0ca326dd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -3,18 +3,19 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.CastItemBinding import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.cast_item.view.* -class ActorAdaptor() : RecyclerView.Adapter() { +class ActorAdaptor( + private var nextFocusUpId: Int? = null, + private val focusCallback: (View?) -> Unit = {} +) : RecyclerView.Adapter() { data class ActorMetaData( var isInverted: Boolean, val actor: ActorData, @@ -24,7 +25,8 @@ class ActorAdaptor() : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return CardViewHolder( - LayoutInflater.from(parent.context).inflate(R.layout.cast_item, parent, false), + CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + focusCallback ) } @@ -66,17 +68,11 @@ class ActorAdaptor() : RecyclerView.Adapter() { } } - private class CardViewHolder - constructor( - itemView: View, + private inner class CardViewHolder( + val binding: CastItemBinding, + private val focusCallback: (View?) -> Unit = {} ) : - RecyclerView.ViewHolder(itemView) { - private val actorImage: ImageView = itemView.actor_image - private val actorName: TextView = itemView.actor_name - private val actorExtra: TextView = itemView.actor_extra - private val voiceActorImage: ImageView = itemView.voice_actor_image - private val voiceActorImageHolder: View = itemView.voice_actor_image_holder - private val voiceActorName: TextView = itemView.voice_actor_name + RecyclerView.ViewHolder(binding.root) { fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { @@ -85,43 +81,63 @@ class ActorAdaptor() : RecyclerView.Adapter() { Pair(actor.voiceActor?.image, actor.actor.image) } + // Fix tv focus escaping the recyclerview + if (position == 0) { + itemView.nextFocusLeftId = R.id.result_cast_items + } else if ((position - 1) == itemCount) { + itemView.nextFocusRightId = R.id.result_cast_items + } + nextFocusUpId?.let { + itemView.nextFocusUpId = it + } + + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } + itemView.setOnClickListener { callback(position) } - actorImage.setImage(mainImg) + binding.apply { + actorImage.setImage(mainImg) - actorName.text = actor.actor.name - actor.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } - ActorRole.Supporting -> { - R.string.actor_supporting - } - ActorRole.Background -> { - R.string.actor_background + actorName.text = actor.actor.name + actor.role?.let { + actorExtra.context?.getString( + when (it) { + ActorRole.Main -> { + R.string.actor_main + } + + ActorRole.Supporting -> { + R.string.actor_supporting + } + + ActorRole.Background -> { + R.string.actor_background + } } + )?.let { text -> + actorExtra.isVisible = true + actorExtra.text = text } - )?.let { text -> + } ?: actor.roleString?.let { actorExtra.isVisible = true - actorExtra.text = text + actorExtra.text = it + } ?: run { + actorExtra.isVisible = false } - } ?: actor.roleString?.let { - actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false - } - if (actor.voiceActor == null) { - voiceActorImageHolder.isVisible = false - voiceActorName.isVisible = false - } else { - voiceActorName.text = actor.voiceActor.name - voiceActorImageHolder.isVisible = voiceActorImage.setImage(vaImage) + if (actor.voiceActor == null) { + voiceActorImageHolder.isVisible = false + voiceActorName.isVisible = false + } else { + voiceActorName.text = actor.voiceActor?.name + voiceActorImageHolder.isVisible = voiceActorImage.setImage(vaImage) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 0932b001..d12521b3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -3,35 +3,32 @@ package com.lagradost.cloudstream3.ui.result import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.widget.ContentLoadingProgressBar import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.ResultEpisodeBinding +import com.lagradost.cloudstream3.databinding.ResultEpisodeLargeBinding +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD -import com.lagradost.cloudstream3.ui.download.DownloadButtonViewHolder +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.ui.download.EasyDownloadButton -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.html +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.android.synthetic.main.result_episode.view.* -import kotlinx.android.synthetic.main.result_episode.view.episode_text -import kotlinx.android.synthetic.main.result_episode_large.view.* -import kotlinx.android.synthetic.main.result_episode_large.view.episode_filler -import kotlinx.android.synthetic.main.result_episode_large.view.episode_progress -import kotlinx.android.synthetic.main.result_episode_large.view.result_episode_download -import kotlinx.android.synthetic.main.result_episode_large.view.result_episode_progress_downloaded -import java.util.* +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 const val ACTION_PLAY_EPISODE_IN_VLC_PLAYER = 2 @@ -59,6 +56,9 @@ const val ACTION_PLAY_EPISODE_IN_WEB_VIDEO = 16 const val ACTION_PLAY_EPISODE_IN_MPV = 17 const val ACTION_MARK_AS_WATCHED = 18 +const val ACTION_FCAST = 19 + +const val TV_EP_SIZE = 400 data class EpisodeClickEvent(val action: Int, val data: ResultEpisode) @@ -88,49 +88,10 @@ class EpisodeAdapter( var cardList: MutableList = mutableListOf() - private val mBoundViewHolders: HashSet = HashSet() - private fun getAllBoundViewHolders(): Set? { - return Collections.unmodifiableSet(mBoundViewHolders) - } - - fun killAdapter() { - getAllBoundViewHolders()?.forEach { view -> - view?.downloadButton?.dispose() - } - } - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } - //(holder.itemView as? FrameLayout?)?.descendantFocusability = - // ViewGroup.FOCUS_BLOCK_DESCENDANTS - - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - } - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - mBoundViewHolders.remove(holder) - //(holder.itemView as? FrameLayout?)?.descendantFocusability = - // ViewGroup.FOCUS_BLOCK_DESCENDANTS - } - } - - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - //println("onViewAttachedToWindow = ${holder.absoluteAdapterPosition}") - //holder.itemView.post { - // if (holder.itemView.isAttachedToWindow) - // (holder.itemView as? FrameLayout?)?.descendantFocusability = - // ViewGroup.FOCUS_AFTER_DESCENDANTS - //} - - holder.reattachDownloadButton() - } } fun updateList(newList: List) { @@ -144,27 +105,62 @@ class EpisodeAdapter( diffResult.dispatchUpdatesTo(this) } - var layout = R.layout.result_episode_both + private fun getItem(position: Int): ResultEpisode { + return cardList[position] + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) 0 else 1 + } + + + // private val layout = R.layout.result_episode_both override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { /*val layout = if (cardList.filter { it.poster != null }.size >= cardList.size / 2) R.layout.result_episode_large else R.layout.result_episode*/ - return EpisodeCardViewHolder( - LayoutInflater.from(parent.context) - .inflate(layout, parent, false), - hasDownloadSupport, - clickCallback, - downloadClickCallback - ) + return when (viewType) { + 0 -> { + EpisodeCardViewHolderSmall( + ResultEpisodeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + hasDownloadSupport, + clickCallback, + downloadClickCallback + ) + } + + 1 -> { + EpisodeCardViewHolderLarge( + ResultEpisodeLargeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + hasDownloadSupport, + clickCallback, + downloadClickCallback + ) + } + + else -> throw NotImplementedError() + } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { - is EpisodeCardViewHolder -> { - holder.bind(cardList[position]) - mBoundViewHolders.add(holder) + is EpisodeCardViewHolderLarge -> { + holder.bind(getItem(position)) + } + + is EpisodeCardViewHolderSmall -> { + holder.bind(getItem(position)) } } } @@ -173,91 +169,148 @@ class EpisodeAdapter( return cardList.size } - class EpisodeCardViewHolder - constructor( - itemView: View, + class EpisodeCardViewHolderLarge( + val binding: ResultEpisodeLargeBinding, private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { - override var downloadButton = EasyDownloadButton() - - var episodeDownloadBar: ContentLoadingProgressBar? = null - var episodeDownloadImage: ImageView? = null + ) : RecyclerView.ViewHolder(binding.root) { var localCard: ResultEpisode? = null @SuppressLint("SetTextI18n") fun bind(card: ResultEpisode) { localCard = card + val setWidth = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - val isTrueTv = isTrueTvSettings() + binding.episodeLinHolder.layoutParams.width = setWidth + binding.episodeHolderLarge.layoutParams.width = setWidth + binding.episodeHolder.layoutParams.width = setWidth - val (parentView, otherView) = if (card.poster == null) { - itemView.episode_holder to itemView.episode_holder_large - } else { - itemView.episode_holder_large to itemView.episode_holder - } - parentView.isVisible = true - otherView.isVisible = false - val episodeText: TextView = parentView.episode_text - val episodeFiller: MaterialButton? = parentView.episode_filler - val episodeRating: TextView? = parentView.episode_rating - val episodeDescript: TextView? = parentView.episode_descript - val episodeProgress: ContentLoadingProgressBar? = parentView.episode_progress - val episodePoster: ImageView? = parentView.episode_poster + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + VideoDownloadHelper.DownloadEpisodeCached( + name = card.name, + poster = card.poster, + episode = card.episode, + season = card.season, + id = card.id, + parentId = card.parentId, + rating = card.rating, + description = card.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) + } - episodeDownloadBar = - parentView.result_episode_progress_downloaded - episodeDownloadImage = parentView.result_episode_download + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) + } - val name = - if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" - episodeFiller?.isVisible = card.isFiller == true - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (card.videoWatchState == VideoWatchState.Watched) { - // This cannot be done in getDisplayPosition() as when you have not watched something - // the duration and position is 0 - episodeProgress?.max = 1 - episodeProgress?.progress = 1 - episodeProgress?.isVisible = true - } else { - val displayPos = card.getDisplayPosition() - episodeProgress?.max = (card.duration / 1000).toInt() - episodeProgress?.progress = (displayPos / 1000).toInt() - episodeProgress?.isVisible = displayPos > 0L - } - - episodePoster?.isVisible = episodePoster?.setImage(card.poster) == true - - if (card.rating != null) { - episodeRating?.text = episodeRating?.context?.getString(R.string.rated_format) - ?.format(card.rating.toFloat() / 10f) - } else { - episodeRating?.text = "" - } - - episodeRating?.isGone = episodeRating?.text.isNullOrBlank() - - episodeDescript?.apply { - text = card.description.html() - isGone = text.isNullOrBlank() - setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) - } - } - - if (!isTrueTv) { - episodePoster?.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + else -> { + downloadClickCallback.invoke(it) + } + } } - episodePoster?.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) - return@setOnLongClickListener true + val name = + if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" + episodeFiller.isVisible = card.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (card.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + episodeProgress.max = 1 + episodeProgress.progress = 1 + episodeProgress.isVisible = true + } else { + val displayPos = card.getDisplayPosition() + episodeProgress.max = (card.duration / 1000).toInt() + episodeProgress.progress = (displayPos / 1000).toInt() + episodeProgress.isVisible = displayPos > 0L + } + + episodePoster.isVisible = episodePoster.setImage(card.poster) == true + + if (card.rating != null) { + episodeRating.text = episodeRating.context?.getString(R.string.rated_format) + ?.format(card.rating.toFloat() / 10f) + } else { + episodeRating.text = "" + } + + episodeRating.isGone = episodeRating.text.isNullOrBlank() + + episodeDescript.apply { + text = card.description.html() + isGone = text.isNullOrBlank() + + var isExpanded = false + setOnClickListener { + if (isLayout(TV)) { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) + } else { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 4 + } + } + } + + if (card.airDate != null) { + val isUpcoming = unixTimeMS < card.airDate + + if (isUpcoming) { + episodePlayIcon.isVisible = false + episodeUpcomingIcon.isVisible = !episodePoster.isVisible + episodeDate.setText( + txt( + R.string.episode_upcoming_format, + secondsToReadable( + card.airDate.minus(unixTimeMS).div(1000).toInt(), + "" + ) + ) + ) + } else { + episodeUpcomingIcon.isVisible = false + + val formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).apply { + }.format(Date(card.airDate)) + + episodeDate.setText(txt(formattedAirDate)) + } + } else { + episodeDate.isVisible = false + } + + episodeRuntime.setText( + txt( + card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + ) + ) + + if (isLayout(EMULATOR or PHONE)) { + episodePoster.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + episodePoster.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) + return@setOnLongClickListener true + } } } @@ -265,7 +318,7 @@ class EpisodeAdapter( clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) } - if (isTrueTv) { + if (isLayout(TV)) { itemView.isFocusable = true itemView.isFocusableInTouchMode = true //itemView.touchscreenBlocksFocus = false @@ -276,47 +329,91 @@ class EpisodeAdapter( return@setOnLongClickListener true } - episodeDownloadImage?.isVisible = hasDownloadSupport - episodeDownloadBar?.isVisible = hasDownloadSupport - reattachDownloadButton() + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport } + } - override fun reattachDownloadButton() { - downloadButton.dispose() - val card = localCard - if (hasDownloadSupport && card != null) { - if (episodeDownloadBar == null || - episodeDownloadImage == null - ) return - val downloadInfo = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - itemView.context, - card.id - ) + class EpisodeCardViewHolderSmall( + val binding: ResultEpisodeBinding, + private val hasDownloadSupport: Boolean, + private val clickCallback: (EpisodeClickEvent) -> Unit, + private val downloadClickCallback: (DownloadClickEvent) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + @SuppressLint("SetTextI18n") + fun bind(card: ResultEpisode) { + binding.episodeHolder.layoutParams.apply { + width = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + } - downloadButton.setUpButton( - downloadInfo?.fileLength, - downloadInfo?.totalBytes, - episodeDownloadBar ?: return, - episodeDownloadImage ?: return, - null, + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( VideoDownloadHelper.DownloadEpisodeCached( - card.name, - card.poster, - card.episode, - card.season, - card.id, - card.parentId, - card.rating, - card.description, - System.currentTimeMillis(), - ) + name = card.name, + poster = card.poster, + episode = card.episode, + season = card.season, + id = card.id, + parentId = card.parentId, + rating = card.rating, + description = card.description, + cacheTime = System.currentTimeMillis(), + ), null ) { - if (it.action == DOWNLOAD_ACTION_DOWNLOAD) { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) - } else { - downloadClickCallback.invoke(it) + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) + } + + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) + } + + else -> { + downloadClickCallback.invoke(it) + } } } + + val name = + if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" + episodeFiller.isVisible = card.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (card.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + episodeProgress.max = 1 + episodeProgress.progress = 1 + episodeProgress.isVisible = true + } else { + val displayPos = card.getDisplayPosition() + episodeProgress.max = (card.duration / 1000).toInt() + episodeProgress.progress = (displayPos / 1000).toInt() + episodeProgress.isVisible = displayPos > 0L + } + + itemView.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) + return@setOnLongClickListener true + } + + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index ebd6a658..eecd6262 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -1,30 +1,17 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -/* -class ImageAdapter(context: Context, val resource: Int) : ArrayAdapter(context, resource) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val newConvertView = convertView ?: run { - val mInflater = context - .getSystemService(Activity.LAYOUT_INFLATER_SERVICE) as LayoutInflater - mInflater.inflate(resource, null) - } - getItem(position)?.let { (newConvertView as? ImageView?)?.setImageResource(it) } - return newConvertView - } -}*/ const val IMAGE_CLICK = 0 const val IMAGE_LONG_CLICK = 1 class ImageAdapter( - val layout: Int, val clickCallback: ((Int) -> Unit)? = null, val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, @@ -34,7 +21,9 @@ class ImageAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return ImageViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false) + //result_mini_image + ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) + // LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } @@ -65,16 +54,15 @@ class ImageAdapter( diffResult.dispatchUpdatesTo(this) } - class ImageViewHolder - constructor(itemView: View) : - RecyclerView.ViewHolder(itemView) { + class ImageViewHolder(val binding: ResultMiniImageBinding) : + RecyclerView.ViewHolder(binding.root) { fun bind( img: Int, clickCallback: ((Int) -> Unit)?, nextFocusUp: Int?, nextFocusDown: Int?, ) { - (itemView as? ImageView?)?.apply { + binding.root.apply { setImageResource(img) if (nextFocusDown != null) { this.nextFocusDownId = nextFocusDown @@ -83,7 +71,7 @@ class ImageAdapter( this.nextFocusUpId = nextFocusUp } if (clickCallback != null) { - if (isTrueTvSettings()) { + if (isLayout(TV)) { isClickable = true isLongClickable = true isFocusable = true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt index 59a46264..b4e3062b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt @@ -4,18 +4,45 @@ import android.content.Context import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.FocusDirection import com.lagradost.cloudstream3.mvvm.logError -fun RecyclerView?.setLinearListLayout(isHorizontal: Boolean = true) { - if(this == null) return +const val FOCUS_SELF = View.NO_ID - 1 +const val FOCUS_INHERIT = FOCUS_SELF - 1 + +fun RecyclerView?.setLinearListLayout( + isHorizontal: Boolean = true, + nextLeft: Int = FOCUS_INHERIT, + nextRight: Int = FOCUS_INHERIT, + nextUp: Int = FOCUS_INHERIT, + nextDown: Int = FOCUS_INHERIT +) { + if (this == null) return + val ctx = this.context ?: return this.layoutManager = - this.context?.let { LinearListLayout(it).apply { if (isHorizontal) setHorizontal() else setVertical() } } - ?: this.layoutManager + LinearListLayout(ctx).apply { + if (isHorizontal) setHorizontal() else setVertical() + nextFocusLeft = + if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft + nextFocusRight = + if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight + nextFocusUp = + if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp + nextFocusDown = + if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown + } } -class LinearListLayout(context: Context?) : +open class LinearListLayout(context: Context?) : LinearLayoutManager(context) { + var nextFocusLeft: Int = View.NO_ID + var nextFocusRight: Int = View.NO_ID + var nextFocusUp: Int = View.NO_ID + var nextFocusDown: Int = View.NO_ID + fun setHorizontal() { orientation = HORIZONTAL } @@ -24,7 +51,8 @@ class LinearListLayout(context: Context?) : orientation = VERTICAL } - private fun getCorrectParent(focused: View): View? { + private fun getCorrectParent(focused: View?): View? { + if (focused == null) return null var current: View? = focused val last: ArrayList = arrayListOf(focused) while (current != null && current !is RecyclerView) { @@ -55,27 +83,86 @@ class LinearListLayout(context: Context?) : startSmoothScroll(linearSmoothScroller) }*/ + /** from the current focus go to a direction */ + private fun getNextDirection(focused: View?, direction: FocusDirection): View? { + val id = when (direction) { + FocusDirection.Start -> if (isLayoutRTL) nextFocusRight else nextFocusLeft + FocusDirection.End -> if (isLayoutRTL) nextFocusLeft else nextFocusRight + FocusDirection.Up -> nextFocusUp + FocusDirection.Down -> nextFocusDown + } + + return when (id) { + View.NO_ID -> null + FOCUS_SELF -> focused + else -> CommonActivity.continueGetNextFocus( + activity ?: focused, + focused ?: return null, + direction, + id + ) + } + } + override fun onInterceptFocusSearch(focused: View, direction: Int): View? { val dir = if (orientation == HORIZONTAL) { - if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) return null - if (direction == View.FOCUS_RIGHT) 1 else -1 + if (direction == View.FOCUS_DOWN) getNextDirection(focused, FocusDirection.Down)?.let { newFocus -> + return newFocus + } + if (direction == View.FOCUS_UP) getNextDirection(focused, FocusDirection.Up)?.let { newFocus -> + return newFocus + } + + if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { + // This scrolls the recyclerview before doing focus search, which + // allows the focus search to work better. + + // Without this the recyclerview focus location on the screen + // would change when scrolling between recyclerviews. + (focused.parent as? RecyclerView)?.focusSearch(direction) + return null + } + var ret = if (direction == View.FOCUS_RIGHT) 1 else -1 + // only flip on horizontal layout + if (isLayoutRTL) { + ret = -ret + } + ret } else { - if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) return null + if (direction == View.FOCUS_RIGHT) getNextDirection(focused, FocusDirection.End)?.let { newFocus -> + return newFocus + } + if (direction == View.FOCUS_LEFT) getNextDirection(focused, FocusDirection.Start)?.let { newFocus -> + return newFocus + } + + if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) { + (focused.parent as? RecyclerView)?.focusSearch(direction) + return null + } + + //if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) return null if (direction == View.FOCUS_DOWN) 1 else -1 } - return try { - getPosition(getCorrectParent(focused))?.let { position -> - val lookfor = dir + position - //clamp(dir + position, 0, recyclerView.adapter?.itemCount ?: return null) - getViewFromPos(lookfor) ?: run { - scrollToPosition(lookfor) + try { + val position = getPosition(getCorrectParent(focused)) ?: return null + val lookFor = dir + position + + // if out of bounds then refocus as specified + return if (lookFor >= itemCount) { + getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down) + } else if (lookFor < 0) { + getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up) + } else { + getViewFromPos(lookFor) ?: run { + scrollToPosition(lookFor) null } } } catch (e: Exception) { logError(e) - null + return null } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index 68dd1c0e..3eab0c71 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -1,108 +1,18 @@ package com.lagradost.cloudstream3.ui.result -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.content.Intent.* -import android.content.res.ColorStateList -import android.net.Uri -import android.os.Build import android.os.Bundle -import android.text.Editable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AbsListView -import android.widget.ArrayAdapter -import android.widget.ImageView -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.ViewModelProvider +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager -import com.discord.panels.OverlappingPanelsLayout -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipDrawable -import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings -import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.APIHolder.updateHasTrailers import com.lagradost.cloudstream3.DubStatus -import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.mvvm.* -import com.lagradost.cloudstream3.syncproviders.providers.Kitsu -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.ui.download.EasyDownloadButton -import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.AppUtils.loadCache -import com.lagradost.cloudstream3.utils.AppUtils.openBrowser -import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe -import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_result.result_cast_items -import kotlinx.android.synthetic.main.fragment_result.result_cast_text -import kotlinx.android.synthetic.main.fragment_result.result_coming_soon -import kotlinx.android.synthetic.main.fragment_result.result_data_holder -import kotlinx.android.synthetic.main.fragment_result.result_description -import kotlinx.android.synthetic.main.fragment_result.result_download_movie -import kotlinx.android.synthetic.main.fragment_result.result_episode_loading -import kotlinx.android.synthetic.main.fragment_result.result_episodes -import kotlinx.android.synthetic.main.fragment_result.result_error_text -import kotlinx.android.synthetic.main.fragment_result.result_finish_loading -import kotlinx.android.synthetic.main.fragment_result.result_info -import kotlinx.android.synthetic.main.fragment_result.result_loading -import kotlinx.android.synthetic.main.fragment_result.result_loading_error -import kotlinx.android.synthetic.main.fragment_result.result_meta_duration -import kotlinx.android.synthetic.main.fragment_result.result_meta_rating -import kotlinx.android.synthetic.main.fragment_result.result_meta_site -import kotlinx.android.synthetic.main.fragment_result.result_meta_type -import kotlinx.android.synthetic.main.fragment_result.result_meta_year -import kotlinx.android.synthetic.main.fragment_result.result_movie_download_icon -import kotlinx.android.synthetic.main.fragment_result.result_movie_download_text -import kotlinx.android.synthetic.main.fragment_result.result_movie_download_text_precentage -import kotlinx.android.synthetic.main.fragment_result.result_movie_progress_downloaded -import kotlinx.android.synthetic.main.fragment_result.result_movie_progress_downloaded_holder -import kotlinx.android.synthetic.main.fragment_result.result_next_airing -import kotlinx.android.synthetic.main.fragment_result.result_next_airing_time -import kotlinx.android.synthetic.main.fragment_result.result_no_episodes -import kotlinx.android.synthetic.main.fragment_result.result_play_movie -import kotlinx.android.synthetic.main.fragment_result.result_poster -import kotlinx.android.synthetic.main.fragment_result.result_poster_holder -import kotlinx.android.synthetic.main.fragment_result.result_reload_connection_open_in_browser -import kotlinx.android.synthetic.main.fragment_result.result_reload_connectionerror -import kotlinx.android.synthetic.main.fragment_result.result_resume_parent -import kotlinx.android.synthetic.main.fragment_result.result_resume_progress_holder -import kotlinx.android.synthetic.main.fragment_result.result_resume_series_progress -import kotlinx.android.synthetic.main.fragment_result.result_resume_series_progress_text -import kotlinx.android.synthetic.main.fragment_result.result_resume_series_title -import kotlinx.android.synthetic.main.fragment_result.result_tag -import kotlinx.android.synthetic.main.fragment_result.result_tag_holder -import kotlinx.android.synthetic.main.fragment_result.result_title -import kotlinx.android.synthetic.main.fragment_result.result_vpn -import kotlinx.android.synthetic.main.fragment_result_swipe.* -import kotlinx.android.synthetic.main.fragment_result_tv.* -import kotlinx.android.synthetic.main.result_sync.* -import kotlinx.android.synthetic.main.trailer_custom_layout.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking +import com.lagradost.cloudstream3.utils.Event const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 @@ -137,7 +47,11 @@ data class ResultEpisode( /** * Conveys if the episode itself is marked as watched **/ - val videoWatchState: VideoWatchState + val videoWatchState: VideoWatchState, + /** Sum of all previous season episode counts + episode */ + val totalEpisodeIndex: Int? = null, + val airDate: Long? = null, + val runTime: Int? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -172,6 +86,9 @@ fun buildResultEpisode( isFiller: Boolean? = null, tvType: TvType, parentId: Int, + totalEpisodeIndex: Int? = null, + airDate: Long? = null, + runTime: Int? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None @@ -193,7 +110,10 @@ fun buildResultEpisode( isFiller, tvType, parentId, - videoWatchState + videoWatchState, + totalEpisodeIndex, + airDate, + runTime, ) } @@ -202,286 +122,113 @@ fun ResultEpisode.getWatchProgress(): Float { return (getDisplayPosition() / 1000).toFloat() / (duration / 1000).toFloat() } -open class ResultFragment : ResultTrailerPlayer() { - companion object { - const val URL_BUNDLE = "url" - const val API_NAME_BUNDLE = "apiName" - const val SEASON_BUNDLE = "season" - const val EPISODE_BUNDLE = "episode" - const val START_ACTION_BUNDLE = "startAction" - const val START_VALUE_BUNDLE = "startValue" - const val RESTART_BUNDLE = "restart" +object ResultFragment { + private const val URL_BUNDLE = "url" + private const val API_NAME_BUNDLE = "apiName" + private const val SEASON_BUNDLE = "season" + private const val EPISODE_BUNDLE = "episode" + private const val START_ACTION_BUNDLE = "startAction" + private const val START_VALUE_BUNDLE = "startValue" + private const val RESTART_BUNDLE = "restart" - fun newInstance( - card: SearchResponse, startAction: Int = 0, startValue: Int? = null - ): Bundle { - return Bundle().apply { - putString(URL_BUNDLE, card.url) - putString(API_NAME_BUNDLE, card.apiName) - if (card is DataStoreHelper.ResumeWatchingResult) { - if (card.season != null) - putInt(SEASON_BUNDLE, card.season) - if (card.episode != null) - putInt(EPISODE_BUNDLE, card.episode) - } - putInt(START_ACTION_BUNDLE, startAction) - if (startValue != null) - putInt(START_VALUE_BUNDLE, startValue) - - - putBoolean(RESTART_BUNDLE, true) + fun newInstance( + card: SearchResponse, startAction: Int = 0, startValue: Int? = null + ): Bundle { + return Bundle().apply { + putString(URL_BUNDLE, card.url) + putString(API_NAME_BUNDLE, card.apiName) + if (card is DataStoreHelper.ResumeWatchingResult) { + if (card.season != null) + putInt(SEASON_BUNDLE, card.season) + if (card.episode != null) + putInt(EPISODE_BUNDLE, card.episode) } - } - - fun newInstance( - url: String, - apiName: String, - startAction: Int = 0, - startValue: Int = 0 - ): Bundle { - return Bundle().apply { - putString(URL_BUNDLE, url) - putString(API_NAME_BUNDLE, apiName) - putInt(START_ACTION_BUNDLE, startAction) + putInt(START_ACTION_BUNDLE, startAction) + if (startValue != null) putInt(START_VALUE_BUNDLE, startValue) - putBoolean(RESTART_BUNDLE, true) - } - } - fun updateUI() { - updateUIListener?.invoke() - } - private var updateUIListener: (() -> Unit)? = null - } - - open fun setTrailers(trailers: List?) {} - - protected lateinit var viewModel: ResultViewModel2 //by activityViewModels() - protected lateinit var syncModel: SyncViewModel - protected open val resultLayout = R.layout.fragment_result_swipe - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - viewModel = - ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = - ViewModelProvider(this)[SyncViewModel::class.java] - - return inflater.inflate(resultLayout, container, false) - } - - private var downloadButton: EasyDownloadButton? = null - override fun onDestroyView() { - updateUIListener = null - (result_episodes?.adapter as? EpisodeAdapter)?.killAdapter() - downloadButton?.dispose() - - super.onDestroyView() - } - - override fun onResume() { - afterPluginsLoadedEvent += ::reloadViewModel - super.onResume() - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) + putBoolean(RESTART_BUNDLE, true) } } - override fun onDestroy() { - afterPluginsLoadedEvent -= ::reloadViewModel - super.onDestroy() - } - - /// 0 = LOADING, 1 = ERROR LOADING, 2 = LOADED - private fun updateVisStatus(state: Int) { - when (state) { - 0 -> { - result_bookmark_fab?.isGone = true - result_loading?.isVisible = true - result_finish_loading?.isVisible = false - result_loading_error?.isVisible = false - } - 1 -> { - result_bookmark_fab?.isGone = true - result_loading?.isVisible = false - result_finish_loading?.isVisible = false - result_loading_error?.isVisible = true - result_reload_connection_open_in_browser?.isVisible = true - } - 2 -> { - result_bookmark_fab?.isGone = isTrueTvSettings() - result_bookmark_fab?.extend() - //if (result_bookmark_button?.context?.isTrueTvSettings() == true) { - // when { - // result_play_movie?.isVisible == true -> { - // result_play_movie?.requestFocus() - // } - // result_resume_series_button?.isVisible == true -> { - // result_resume_series_button?.requestFocus() - // } - // else -> { - // result_bookmark_button?.requestFocus() - // } - // } - //} - - result_loading?.isVisible = false - result_finish_loading?.isVisible = true - result_loading_error?.isVisible = false - } + fun newInstance( + url: String, + apiName: String, + startAction: Int = 0, + startValue: Int = 0 + ): Bundle { + return Bundle().apply { + putString(URL_BUNDLE, url) + putString(API_NAME_BUNDLE, apiName) + putInt(START_ACTION_BUNDLE, startAction) + putInt(START_VALUE_BUNDLE, startValue) + putBoolean(RESTART_BUNDLE, true) } } - open fun setRecommendations(rec: List?, validApiName: String?) { - + fun updateUI(id: Int? = null) { + // updateUIListener?.invoke() + updateUIEvent.invoke(id) } + val updateUIEvent = Event() - private fun updateUI() { - syncModel.updateUserData() - viewModel.reloadEpisodes() - } + //private var updateUIListener: (() -> Unit)? = null - open fun updateMovie(data: ResourceSome>) { - when (data) { - is ResourceSome.Success -> { - data.value.let { (text, ep) -> - result_play_movie.setText(text) - result_play_movie?.setOnClickListener { - viewModel.handleAction( - activity, - EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) - ) - } - result_play_movie?.setOnLongClickListener { - viewModel.handleAction( - activity, - EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) - ) - return@setOnLongClickListener true - } - main { - val file = - ioWorkSafe { - context?.let { - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - it, - ep.id - ) - } - } + //protected open val resultLayout = R.layout.fragment_result_swipe - downloadButton?.dispose() - downloadButton = EasyDownloadButton() - downloadButton?.setUpMoreButton( - file?.fileLength, - file?.totalBytes, - result_movie_progress_downloaded ?: return@main, - result_movie_download_icon ?: return@main, - result_movie_download_text ?: return@main, - result_movie_download_text_precentage ?: return@main, - result_download_movie ?: return@main, - true, - VideoDownloadHelper.DownloadEpisodeCached( - ep.name, - ep.poster, - 0, - null, - ep.id, - ep.id, - null, - null, - System.currentTimeMillis(), - ) - ) { click -> - when (click.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - viewModel.handleAction( - activity, - EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) - ) - } - else -> handleDownloadClick(activity, click) - } - } - result_movie_progress_downloaded_holder?.isVisible = true - } - } - } - else -> { - result_movie_progress_downloaded_holder?.isVisible = false - result_play_movie?.isVisible = false - } - } - } + /* override var layout = R.layout.fragment_result_swipe - open fun updateEpisodes(episodes: ResourceSome>) { - when (episodes) { - is ResourceSome.None -> { - result_episode_loading?.isVisible = false - result_episodes?.isVisible = false - } - is ResourceSome.Loading -> { - result_episode_loading?.isVisible = true - result_episodes?.isVisible = false - } - is ResourceSome.Success -> { - result_episodes?.isVisible = true - result_episode_loading?.isVisible = false + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { - /* - * Okay so what is this fuckery? - * Basically Android TV will crash if you request a new focus while - * the adapter gets updated. - * - * This means that if you load thumbnails and request a next focus at the same time - * the app will crash without any way to catch it! - * - * How to bypass this? - * This code basically steals the focus for 500ms and puts it in an inescapable view - * then lets out the focus by requesting focus to result_episodes - */ + return super.onCreateView(inflater, container, savedInstanceState) + //return inflater.inflate(resultLayout, container, false) + } - // Do not use this.isTv, that is the player - val isTv = isTvSettings() - val hasEpisodes = - !(result_episodes?.adapter as? EpisodeAdapter?)?.cardList.isNullOrEmpty() + override fun onDestroyView() { + updateUIListener = null + super.onDestroyView() + } - if (isTv && hasEpisodes) { - // Make it impossible to focus anywhere else! - temporary_no_focus?.isFocusable = true - temporary_no_focus?.requestFocus() - } + override fun onResume() { + afterPluginsLoadedEvent += ::reloadViewModel + super.onResume() + activity?.let { + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } + } - (result_episodes?.adapter as? EpisodeAdapter)?.updateList(episodes.value) + override fun onDestroy() { + afterPluginsLoadedEvent -= ::reloadViewModel + super.onDestroy() + } - if (isTv && hasEpisodes) main { - delay(500) - temporary_no_focus?.isFocusable = false - // This might make some people sad as it changes the focus when leaving an episode :( - result_episodes?.requestFocus() - } - } - } - } + + private fun updateUI() { + syncModel.updateUserData() + viewModel.reloadEpisodes() + }*/ data class StoredData( - val url: String?, + val url: String, val apiName: String, val showFillers: Boolean, val dubStatus: DubStatus, val start: AutoResume?, - val playerAction: Int + val playerAction: Int, + val restart : Boolean, ) - private fun getStoredData(context: Context): StoredData? { + fun Fragment.getStoredData(): StoredData? { + val context = this.context ?: this.activity ?: return null val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val url = arguments?.getString(URL_BUNDLE) + val url = arguments?.getString(URL_BUNDLE) ?: return null val apiName = arguments?.getString(API_NAME_BUNDLE) ?: return null val showFillers = settingsManager.getBoolean(context.getString(R.string.show_fillers_key), false) @@ -492,6 +239,11 @@ open class ResultFragment : ResultTrailerPlayer() { val playerAction = getPlayerAction(context) + val restart = arguments?.getBoolean(RESTART_BUNDLE) ?: false + if (restart) { + arguments?.putBoolean(RESTART_BUNDLE, false) + } + val start = startAction?.let { action -> val startValue = arguments?.getInt(START_VALUE_BUNDLE) val resumeEpisode = arguments?.getInt(EPISODE_BUNDLE) @@ -506,10 +258,10 @@ open class ResultFragment : ResultTrailerPlayer() { season = resumeSeason ) } - return StoredData(url, apiName, showFillers, dubStatus, start, playerAction) + return StoredData(url, apiName, showFillers, dubStatus, start, playerAction, restart) } - private fun reloadViewModel(forceReload: Boolean) { + /*private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { val storedData = getStoredData(activity ?: context ?: return) ?: return @@ -528,7 +280,6 @@ open class ResultFragment : ResultTrailerPlayer() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - result_cast_items?.adapter = ActorAdaptor() updateUIListener = ::updateUI @@ -542,7 +293,6 @@ open class ResultFragment : ResultTrailerPlayer() { context?.updateHasTrailers() activity?.loadCache() - activity?.fixPaddingStatusbar(result_top_bar) //activity?.fixPaddingStatusbar(result_barstatus) /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams @@ -559,503 +309,13 @@ open class ResultFragment : ResultTrailerPlayer() { val storedData = (activity ?: context)?.let { getStoredData(it) } - syncModel.addFromUrl(storedData?.url) - - val api = getApiFromNameNull(storedData?.apiName) - - result_episodes?.adapter = - EpisodeAdapter( - api?.hasDownloadSupport == true, - { episodeClick -> - viewModel.handleAction(activity, episodeClick) - }, - { downloadClickEvent -> - handleDownloadClick(activity, downloadClickEvent) - } - ) - - - observe(viewModel.episodeSynopsis) { description -> - view.context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(description.html()) - .setTitle(R.string.synopsis) - .setOnDismissListener { - viewModel.releaseEpisodeSynopsis() - } - .show() - } - } - - observe(viewModel.watchStatus) { watchType -> - result_bookmark_button?.text = getString(watchType.stringRes) - result_bookmark_fab?.text = getString(watchType.stringRes) - - if (watchType == WatchType.NONE) { - result_bookmark_fab?.context?.colorFromAttribute(R.attr.white) - } else { - result_bookmark_fab?.context?.colorFromAttribute(R.attr.colorPrimary) - }?.let { - val colorState = ColorStateList.valueOf(it) - result_bookmark_fab?.iconTint = colorState - result_bookmark_fab?.setTextColor(colorState) - } - - result_bookmark_fab?.setOnClickListener { fab -> - activity?.showBottomDialog( - WatchType.values().map { fab.context.getString(it.stringRes) }.toList(), - watchType.ordinal, - fab.context.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - viewModel.updateWatchStatus(WatchType.values()[it]) - } - } - - result_bookmark_button?.setOnClickListener { fab -> - activity?.showBottomDialog( - WatchType.values().map { fab.context.getString(it.stringRes) }.toList(), - watchType.ordinal, - fab.context.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - viewModel.updateWatchStatus(WatchType.values()[it]) - } - } - } // This is to band-aid FireTV navigation val isTv = isTvSettings() result_season_button?.isFocusableInTouchMode = isTv result_episode_select?.isFocusableInTouchMode = isTv result_dub_select?.isFocusableInTouchMode = isTv - - context?.let { ctx -> - val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - /* - -1 -> None - 0 -> Watching - 1 -> Completed - 2 -> OnHold - 3 -> Dropped - 4 -> PlanToWatch - 5 -> ReWatching - */ - val items = listOf( - R.string.none, - R.string.type_watching, - R.string.type_completed, - R.string.type_on_hold, - R.string.type_dropped, - R.string.type_plan_to_watch, - R.string.type_re_watching - ).map { ctx.getString(it) } - arrayAdapter.addAll(items) - result_sync_check?.choiceMode = AbsListView.CHOICE_MODE_SINGLE - result_sync_check?.adapter = arrayAdapter - UIHelper.setListViewHeightBasedOnItems(result_sync_check) - - result_sync_check?.setOnItemClickListener { _, _, which, _ -> - syncModel.setStatus(which - 1) - } - - result_sync_rating?.addOnChangeListener { _, value, _ -> - syncModel.setScore(value.toInt()) - } - - result_sync_add_episode?.setOnClickListener { - syncModel.setEpisodesDelta(1) - } - - result_sync_sub_episode?.setOnClickListener { - syncModel.setEpisodesDelta(-1) - } - - result_sync_current_episodes?.doOnTextChanged { text, _, before, count -> - if (count == before) return@doOnTextChanged - text?.toString()?.toIntOrNull()?.let { ep -> - syncModel.setEpisodes(ep) - } - } - } - - observe(syncModel.synced) { list -> - result_sync_names?.text = - list.filter { it.isSynced && it.hasAccount }.joinToString { it.name } - - val newList = list.filter { it.isSynced && it.hasAccount } - - result_mini_sync?.isVisible = newList.isNotEmpty() - (result_mini_sync?.adapter as? ImageAdapter)?.updateList(newList.mapNotNull { it.icon }) - } - - var currentSyncProgress = 0 - - fun setSyncMaxEpisodes(totalEpisodes: Int?) { - result_sync_episodes?.max = (totalEpisodes ?: 0) * 1000 - - normalSafeApiCall { - val ctx = result_sync_max_episodes?.context - result_sync_max_episodes?.text = - totalEpisodes?.let { episodes -> - ctx?.getString(R.string.sync_total_episodes_some)?.format(episodes) - } ?: run { - ctx?.getString(R.string.sync_total_episodes_none) - } - } - } - - observe(syncModel.metadata) { meta -> - when (meta) { - is Resource.Success -> { - val d = meta.value - result_sync_episodes?.progress = currentSyncProgress * 1000 - setSyncMaxEpisodes(d.totalEpisodes) - - viewModel.setMeta(d, syncModel.getSyncs()) - } - is Resource.Loading -> { - result_sync_max_episodes?.text = - result_sync_max_episodes?.context?.getString(R.string.sync_total_episodes_none) - } - else -> {} - } - } - - observe(syncModel.userData) { status -> - var closed = false - when (status) { - is Resource.Failure -> { - result_sync_loading_shimmer?.stopShimmer() - result_sync_loading_shimmer?.isVisible = false - result_sync_holder?.isVisible = false - closed = true - } - is Resource.Loading -> { - result_sync_loading_shimmer?.startShimmer() - result_sync_loading_shimmer?.isVisible = true - result_sync_holder?.isVisible = false - } - is Resource.Success -> { - result_sync_loading_shimmer?.stopShimmer() - result_sync_loading_shimmer?.isVisible = false - result_sync_holder?.isVisible = true - - val d = status.value - result_sync_rating?.value = d.score?.toFloat() ?: 0.0f - result_sync_check?.setItemChecked(d.status + 1, true) - val watchedEpisodes = d.watchedEpisodes ?: 0 - currentSyncProgress = watchedEpisodes - - d.maxEpisodes?.let { - // don't directly call it because we don't want to override metadata observe - setSyncMaxEpisodes(it) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - result_sync_episodes?.setProgress(watchedEpisodes * 1000, true) - } else { - result_sync_episodes?.progress = watchedEpisodes * 1000 - } - result_sync_current_episodes?.text = - Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) - normalSafeApiCall { // format might fail - context?.getString(R.string.sync_score_format)?.format(d.score ?: 0)?.let { - result_sync_score_text?.text = it - } - } - } - null -> { - closed = false - } - } - result_overlapping_panels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) - } - - observe(viewModel.resumeWatching) { resume -> - when (resume) { - is Some.Success -> { - result_resume_parent?.isVisible = true - val value = resume.value - value.progress?.let { progress -> - result_resume_series_title?.apply { - isVisible = !value.isMovie - text = - if (value.isMovie) null else activity?.getNameFull( - value.result.name, - value.result.episode, - value.result.season - ) - } - result_resume_series_progress_text.setText(progress.progressLeft) - result_resume_series_progress?.apply { - isVisible = true - this.max = progress.maxProgress - this.progress = progress.progress - } - result_resume_progress_holder?.isVisible = true - } ?: run { - result_resume_progress_holder?.isVisible = false - result_resume_series_progress?.isVisible = false - result_resume_series_title?.isVisible = false - result_resume_series_progress_text?.isVisible = false - } - - result_resume_series_button?.isVisible = !value.isMovie - result_resume_series_button_play?.isVisible = !value.isMovie - - val click = View.OnClickListener { - viewModel.handleAction( - activity, - EpisodeClickEvent( - storedData?.playerAction ?: ACTION_PLAY_EPISODE_IN_PLAYER, - value.result - ) - ) - } - - result_resume_series_button?.setOnClickListener(click) - result_resume_series_button_play?.setOnClickListener(click) - } - is Some.None -> { - result_resume_parent?.isVisible = false - } - } - } - - observe(viewModel.episodes) { episodes -> - updateEpisodes(episodes) - } - - result_cast_items?.setOnFocusChangeListener { _, hasFocus -> - // Always escape focus - if (hasFocus) result_bookmark_button?.requestFocus() - } - - result_sync_set_score?.setOnClickListener { - syncModel.publishUserData() - } - - observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! - } - - observe(viewModel.recommendations) { recommendations -> - setRecommendations(recommendations, null) - } - - observe(viewModel.movie) { data -> - updateMovie(data) - } - - observe(viewModel.page) { data -> - if(data == null) return@observe - when (data) { - is Resource.Success -> { - val d = data.value - - updateVisStatus(2) - - result_vpn.setText(d.vpnText) - result_info.setText(d.metaText) - result_no_episodes.setText(d.noEpisodesFoundText) - result_title.setText(d.titleText) - result_meta_site.setText(d.apiName) - result_meta_type.setText(d.typeText) - result_meta_year.setText(d.yearText) - result_meta_duration.setText(d.durationText) - result_meta_rating.setText(d.ratingText) - result_cast_text.setText(d.actorsText) - result_next_airing.setText(d.nextAiringEpisode) - result_next_airing_time.setText(d.nextAiringDate) - result_poster.setImage(d.posterImage) - result_poster_background.setImage(d.posterBackgroundImage) - //result_trailer_thumbnail.setImage(d.posterBackgroundImage, fadeIn = false) - - if (d.posterImage != null && !isTrueTvSettings()) - result_poster_holder?.setOnClickListener { - try { - context?.let { ctx -> - runBlocking { - val sourceBuilder = AlertDialog.Builder(ctx) - sourceBuilder.setView(R.layout.result_poster) - - val sourceDialog = sourceBuilder.create() - sourceDialog.show() - - sourceDialog.findViewById(R.id.imgPoster) - ?.apply { - setImage(d.posterImage) - setOnClickListener { - sourceDialog.dismissSafe() - } - } - } - } - } catch (e: Exception) { - logError(e) - } - } - - - result_cast_items?.isVisible = d.actors != null - (result_cast_items?.adapter as? ActorAdaptor)?.apply { - updateList(d.actors ?: emptyList()) - } - - result_open_in_browser?.isVisible = d.url.startsWith("http") - result_open_in_browser?.setOnClickListener { - val i = Intent(ACTION_VIEW) - i.data = Uri.parse(d.url) - try { - startActivity(i) - } catch (e: Exception) { - logError(e) - } - } - - result_search?.setOnClickListener { - QuickSearchFragment.pushSearch(activity, d.title) - } - - result_share?.setOnClickListener { - try { - val i = Intent(ACTION_SEND) - i.type = "text/plain" - i.putExtra(EXTRA_SUBJECT, d.title) - i.putExtra(EXTRA_TEXT, d.url) - startActivity(createChooser(i, d.title)) - } catch (e: Exception) { - logError(e) - } - } - - if (syncModel.addSyncs(d.syncData)) { - syncModel.updateMetaAndUser() - syncModel.updateSynced() - } else { - syncModel.addFromUrl(d.url) - } - - result_description.setTextHtml(d.plotText) - if (this !is ResultFragmentTv) // dont want this clickable on tv layout - result_description?.setOnClickListener { view -> - view.context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(d.plotText.asString(ctx).html()) - .setTitle(d.plotHeaderText.asString(ctx)) - .show() - } - } - - - d.comingSoon.let { soon -> - result_coming_soon?.isVisible = soon - result_data_holder?.isGone = soon - } - - val tags = d.tags - result_tag_holder?.isVisible = tags.isNotEmpty() - result_tag?.apply { - removeAllViews() - tags.forEach { tag -> - val chip = Chip(context) - val chipDrawable = ChipDrawable.createFromAttributes( - context, - null, - 0, - R.style.ChipFilled - ) - chip.setChipDrawable(chipDrawable) - chip.text = tag - chip.isChecked = false - chip.isCheckable = false - chip.isFocusable = false - chip.isClickable = false - chip.setTextColor(context.colorFromAttribute(R.attr.textColor)) - addView(chip) - } - } - // if (tags.isNotEmpty()) { - //result_tag_holder?.visibility = VISIBLE - //val isOnTv = isTrueTvSettings() - - - /*for ((index, tag) in tags.withIndex()) { - val viewBtt = layoutInflater.inflate(R.layout.result_tag, null) - val btt = viewBtt.findViewById(R.id.result_tag_card) - btt.text = tag - btt.isFocusable = !isOnTv - btt.isClickable = !isOnTv - result_tag?.addView(viewBtt, index) - }*/ - //} - } - is Resource.Failure -> { - result_error_text.text = storedData?.url?.plus("\n") + data.errorString - updateVisStatus(1) - } - is Resource.Loading -> { - updateVisStatus(0) - } - } - } - - context?.let { ctx -> - - //result_bookmark_button?.isVisible = ctx.isTvSettings() - - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - - - Kitsu.isEnabled = - settingsManager.getBoolean(ctx.getString(R.string.show_kitsu_posters_key), true) - if (storedData?.url != null) { - result_reload_connectionerror.setOnClickListener { - viewModel.load( - activity, - storedData.url, - storedData.apiName, - storedData.showFillers, - storedData.dubStatus, - storedData.start - ) - } - - result_reload_connection_open_in_browser?.setOnClickListener { - val i = Intent(ACTION_VIEW) - i.data = Uri.parse(storedData.url) - try { - startActivity(i) - } catch (e: Exception) { - logError(e) - } - } - - result_open_in_browser?.isVisible = storedData.url.startsWith("http") - result_open_in_browser?.setOnClickListener { - val i = Intent(ACTION_VIEW) - i.data = Uri.parse(storedData.url) - try { - startActivity(i) - } catch (e: Exception) { - logError(e) - } - } - - // bloats the navigation on tv - if (!isTrueTvSettings()) { - result_meta_site?.setOnClickListener { - it.context?.openBrowser(storedData.url) - } - result_meta_site?.isFocusable = true - } else { - result_meta_site?.isFocusable = false - } - if (restart || !viewModel.hasLoaded()) { //viewModel.clear() viewModel.load( @@ -1068,6 +328,5 @@ open class ResultFragment : ResultTrailerPlayer() { ) } } - } - } + }*/ } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index b38e1765..97bc49ea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -1,56 +1,132 @@ package com.lagradost.cloudstream3.ui.result +import android.annotation.SuppressLint import android.app.Dialog +import android.content.Intent +import android.content.res.ColorStateList import android.graphics.Rect +import android.os.Build import android.os.Bundle +import android.text.Editable +import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.DecelerateInterpolator +import android.widget.AbsListView +import android.widget.ArrayAdapter import android.widget.Toast import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.ViewModelProvider import com.discord.panels.OverlappingPanelsLayout +import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.updateHasTrailers -import com.lagradost.cloudstream3.mvvm.Some +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.databinding.FragmentResultBinding +import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding +import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding +import com.lagradost.cloudstream3.databinding.ResultSyncBinding +import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CSPlayerEvent +import com.lagradost.cloudstream3.ui.player.FullScreenPlayer +import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData +import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper -import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable -import com.lagradost.cloudstream3.utils.AppUtils.openBrowser +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable +import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache +import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser +import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers +import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_result.result_cast_items -import kotlinx.android.synthetic.main.fragment_result.result_episodes_text -import kotlinx.android.synthetic.main.fragment_result.result_resume_parent -import kotlinx.android.synthetic.main.fragment_result.result_scroll -import kotlinx.android.synthetic.main.fragment_result.result_smallscreen_holder -import kotlinx.android.synthetic.main.fragment_result_swipe.* -import kotlinx.android.synthetic.main.fragment_result_swipe.result_back -import kotlinx.android.synthetic.main.fragment_result_tv.* -import kotlinx.android.synthetic.main.fragment_trailer.* -import kotlinx.android.synthetic.main.result_recommendations.* -import kotlinx.android.synthetic.main.result_recommendations.result_recommendations -import kotlinx.android.synthetic.main.trailer_custom_layout.* +import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +open class ResultFragmentPhone : FullScreenPlayer() { + private val gestureRegionsListener = + object : PanelsChildGestureRegionObserver.GestureRegionsListener { + override fun onGestureRegionsUpdate(gestureRegions: List) { + binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) + } + } + + protected lateinit var viewModel: ResultViewModel2 + protected lateinit var syncModel: SyncViewModel + + protected var binding: FragmentResultSwipeBinding? = null + protected var resultBinding: FragmentResultBinding? = null + protected var recommendationBinding: ResultRecommendationsBinding? = null + protected var syncBinding: ResultSyncBinding? = null + + override var layout = R.layout.fragment_result_swipe + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = + ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = + ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI + + val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null + FragmentResultSwipeBinding.bind(root).let { bind -> + resultBinding = + bind.fragmentResult//FragmentResultBinding.bind(binding.root.findViewById(R.id.fragment_result)) + recommendationBinding = bind.resultRecommendations + syncBinding = bind.resultSync + binding = bind + } + + return root + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { register(it) } + } + } -class ResultFragmentPhone : ResultFragment() { var currentTrailers: List = emptyList() var currentTrailerIndex = 0 @@ -63,8 +139,8 @@ class ResultFragmentPhone : ResultFragment() { return currentTrailerIndex + 1 < currentTrailers.size } - override fun playerError(exception: Exception) { - if (player.getIsPlaying()) { // because we dont want random toasts in player + override fun playerError(exception: Throwable) { + if (player.getIsPlaying()) { // because we don't want random toasts in player super.playerError(exception) } else { nextMirror() @@ -84,7 +160,8 @@ class ResultFragmentPhone : ResultFragment() { startPosition = 0L, subtitles = emptySet(), subtitle = null, - autoPlay = false + autoPlay = false, + preview = false ) true } ?: run { @@ -96,19 +173,28 @@ class ResultFragmentPhone : ResultFragment() { //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) - result_trailer_loading?.isVisible = isSuccess + // result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer - result_smallscreen_holder?.isVisible = turnVis - result_poster_background_holder?.apply { - val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { - interpolator = DecelerateInterpolator() - duration = 200 - fillAfter = true + resultBinding?.apply { + resultSmallscreenHolder.isVisible = turnVis + resultPosterBackgroundHolder.apply { + val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { + interpolator = DecelerateInterpolator() + duration = 200 + fillAfter = true + } + clearAnimation() + startAnimation(fadeIn) } - clearAnimation() - startAnimation(fadeIn) - } + // We don't want the trailer to be focusable if it's not visible + resultSmallscreenHolder.descendantFocusability = if (isSuccess) { + ViewGroup.FOCUS_AFTER_DESCENDANTS + } else { + ViewGroup.FOCUS_BLOCK_DESCENDANTS + } + binding?.resultFullscreenHolder?.isVisible = !isSuccess && isFullScreenPlayer + } //player_view?.apply { //alpha = 0.0f //ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply { @@ -122,18 +208,10 @@ class ResultFragmentPhone : ResultFragment() { // fillAfter = true //} //startAnimation(fadeIn) - // } - - // We don't want the trailer to be focusable if it's not visible - result_smallscreen_holder?.descendantFocusability = if (isSuccess) { - ViewGroup.FOCUS_AFTER_DESCENDANTS - } else { - ViewGroup.FOCUS_BLOCK_DESCENDANTS - } - result_fullscreen_holder?.isVisible = !isSuccess && isFullScreenPlayer + //} } - override fun setTrailers(trailers: List?) { + private fun setTrailers(trailers: List?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return currentTrailers = trailers?.sortedBy { -it.quality } ?: emptyList() @@ -141,15 +219,19 @@ class ResultFragmentPhone : ResultFragment() { } override fun onDestroyView() { - //somehow this still leaks and I dont know why???? - // todo look at https://github.com/discord/OverlappingPanels/blob/70b4a7cf43c6771873b1e091029d332896d41a1a/sample_app/src/main/java/com/discord/sampleapp/MainActivity.kt PanelsChildGestureRegionObserver.Provider.get().let { obs -> - result_cast_items?.let { + resultBinding?.resultCastItems?.let { obs.unregister(it) } - obs.removeGestureRegionsUpdateListener(this) + + obs.removeGestureRegionsUpdateListener(gestureRegionsListener) } + updateUIEvent -= ::updateUI + binding = null + resultBinding = null + syncBinding = null + recommendationBinding = null super.onDestroyView() } @@ -167,39 +249,285 @@ class ResultFragmentPhone : ResultFragment() { } var selectSeason: String? = null + var selectEpisodeRange: String? = null + private fun setUrl(url: String?) { + if (url == null) { + binding?.resultOpenInBrowser?.isVisible = false + return + } + + val valid = url.startsWith("http") + + binding?.resultOpenInBrowser?.apply { + isVisible = valid + setOnClickListener { + context?.openBrowser(url) + } + } + + resultBinding?.resultReloadConnectionOpenInBrowser?.setOnClickListener { + view?.context?.openBrowser(url) + } + + resultBinding?.resultMetaSite?.setOnClickListener { + view?.context?.openBrowser(url) + } + } + + private fun reloadViewModel(forceReload: Boolean) { + if (!viewModel.hasLoaded() || forceReload) { + val storedData = getStoredData() ?: return + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + } + } + + override fun onResume() { + afterPluginsLoadedEvent += ::reloadViewModel + activity?.let { + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } + super.onResume() + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) + } + + override fun onStop() { + afterPluginsLoadedEvent -= ::reloadViewModel + super.onStop() + } + + private fun updateUI(id: Int?) { + syncModel.updateUserData() + viewModel.reloadEpisodes() + } + + @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val apiName = arguments?.getString(API_NAME_BUNDLE) ?: return - super.onViewCreated(view, savedInstanceState) - player_open_source?.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { - context?.openBrowser(it.url) + // ===== setup ===== + UIHelper.fixPaddingStatusbar(binding?.resultTopBar) + val storedData = getStoredData() ?: return + activity?.window?.decorView?.clearFocus() + activity?.loadCache() + context?.updateHasTrailers() + hideKeyboard() + if (storedData.restart || !viewModel.hasLoaded()) + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + + setUrl(storedData.url) + syncModel.addFromUrl(storedData.url) + val api = APIHolder.getApiFromNameNull(storedData.apiName) + + // This may not be 100% reliable, and may delay for small period + // before resultCastItems will be scrollable again, but this does work + // most of the time. + binding?.resultOverlappingPanels?.registerEndPanelStateListeners( + object : OverlappingPanelsLayout.PanelStateListener { + override fun onPanelStateChange(panelState: PanelState) { + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { register(it) } + } + } + } + ) + + // ===== ===== ===== + + resultBinding?.apply { + resultReloadConnectionerror.setOnClickListener { + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + } + + resultCastItems.setLinearListLayout( + isHorizontal = true, + nextLeft = FOCUS_SELF, + nextRight = FOCUS_SELF + ) + /*resultCastItems.layoutManager = object : LinearListLayout(view.context) { + override fun onRequestChildFocus( + parent: RecyclerView, + state: RecyclerView.State, + child: View, + focused: View? + ): Boolean { + // Make the cast always focus the first visible item when focused + // from somewhere else. Otherwise it jumps to the last item. + return if (parent.focusedChild == null) { + scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) + true + } else { + super.onRequestChildFocus(parent, state, child, focused) + } + } + }.apply { + this.orientation = RecyclerView.HORIZONTAL + }*/ + resultCastItems.adapter = ActorAdaptor() + + resultEpisodes.adapter = + EpisodeAdapter( + api?.hasDownloadSupport == true, + { episodeClick -> + viewModel.handleAction(episodeClick) + }, + { downloadClickEvent -> + DownloadButtonSetup.handleDownloadClick(downloadClickEvent) + } + ) + + + resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + val dy = scrollY - oldScrollY + if (dy > 0) { //check for scroll down + binding?.resultBookmarkFab?.shrink() + } else if (dy < -5) { + binding?.resultBookmarkFab?.extend() + } + if (!isFullScreenPlayer && player.getIsPlaying()) { + if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height + ?: scrollY) + ) { + player.handleEvent(CSPlayerEvent.Pause) + } + } + }) + } + + binding?.apply { + resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) + resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) + resultBack.setOnClickListener { + activity?.popCurrentPage() + } + + + resultMiniSync.adapter = ImageAdapter( + nextFocusDown = R.id.result_sync_set_score, + clickCallback = { action -> + if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { + if (binding?.resultOverlappingPanels?.getSelectedPanel()?.ordinal == 1) { + binding?.resultOverlappingPanels?.openStartPanel() + } else { + binding?.resultOverlappingPanels?.closePanels() + } + } + }) + resultSubscribe.setOnClickListener { + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus + + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) + } + context?.let { openBatteryOptimizationSettings(it) } + } + resultFavorite.setOnClickListener { + viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleFavoriteStatus + + val message = if (newStatus) { + R.string.favorite_added + } else { + R.string.favorite_removed + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + mediaRouteButton.apply { + val chromecastSupport = api?.hasChromecastSupport == true + alpha = if (chromecastSupport) 1f else 0.3f + if (!chromecastSupport) { + setOnClickListener { + showToast( + R.string.no_chromecast_support_toast, + Toast.LENGTH_LONG + ) + } + } + activity?.let { act -> + if (act.isCastApiAvailable()) { + try { + CastButtonFactory.setUpMediaRouteButton(act, this) + CastContext.getSharedInstance(act.applicationContext) { + it.run() + }.addOnCompleteListener { + isGone = if (it.isSuccessful) { + it.result.castState == CastState.NO_DEVICES_AVAILABLE + } else { + true + } + + } + // this shit leaks for some reason + //castContext.addCastStateListener { state -> + // media_route_button?.isGone = state == CastState.NO_DEVICES_AVAILABLE + //} + } catch (e: Exception) { + logError(e) + } + } + } } } - result_overlapping_panels?.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) - result_overlapping_panels?.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) - result_recommendations?.spanCount = 3 - result_recommendations?.adapter = - SearchAdapter( - ArrayList(), - result_recommendations, - ) { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + playerBinding?.apply { + playerOpenSource.setOnClickListener { + currentTrailers.getOrNull(currentTrailerIndex)?.let { + context?.openBrowser(it.url) + } } - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) - - result_cast_items?.let { - PanelsChildGestureRegionObserver.Provider.get().register(it) } - - result_back?.setOnClickListener { - activity?.popCurrentPage() + recommendationBinding?.apply { + resultRecommendationsList.apply { + spanCount = 3 + adapter = + SearchAdapter( + ArrayList(), + this, + ) { callback -> + SearchHelper.handleSearchClickCallback(callback) + } + } } + /* result_bookmark_button?.setOnClickListener { it.popupMenuNoIcons( @@ -211,171 +539,527 @@ class ResultFragmentPhone : ResultFragment() { } }*/ - result_mini_sync?.adapter = ImageAdapter( - R.layout.result_mini_image, - nextFocusDown = R.id.result_sync_set_score, - clickCallback = { action -> - if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { - if (result_overlapping_panels?.getSelectedPanel()?.ordinal == 1) { - result_overlapping_panels?.openStartPanel() - } else { - result_overlapping_panels?.closePanels() + observeNullable(viewModel.resumeWatching) { resume -> + resultBinding?.apply { + if (resume == null) { + resultResumeParent.isVisible = false + return@observeNullable + } + resultResumeParent.isVisible = true + resume.progress?.let { progress -> + resultResumeSeriesTitle.apply { + isVisible = !resume.isMovie + text = + if (resume.isMovie) null else context?.getNameFull( + resume.result.name, + resume.result.episode, + resume.result.season + ) } + + resultResumeSeriesProgressText.setText(progress.progressLeft) + resultResumeSeriesProgress.apply { + isVisible = true + this.max = progress.maxProgress + this.progress = progress.progress + } + resultResumeProgressHolder.isVisible = true + } ?: run { + resultResumeProgressHolder.isVisible = false + resultResumeSeriesProgress.isVisible = false + resultResumeSeriesTitle.isVisible = false + resultResumeSeriesProgressText.isVisible = false } - }) - - result_scroll?.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val dy = scrollY - oldScrollY - if (dy > 0) { //check for scroll down - result_bookmark_fab?.shrink() - } else if (dy < -5) { - result_bookmark_fab?.extend() - } - if (!isFullScreenPlayer && player.getIsPlaying()) { - if (scrollY > (player_background?.height ?: scrollY)) { - player.handleEvent(CSPlayerEvent.Pause) - } - } - //result_poster_blur_holder?.translationY = -scrollY.toFloat() - }) - val api = APIHolder.getApiFromNameNull(apiName) - - if (media_route_button != null) { - val chromecastSupport = api?.hasChromecastSupport == true - media_route_button?.alpha = if (chromecastSupport) 1f else 0.3f - if (!chromecastSupport) { - media_route_button?.setOnClickListener { - CommonActivity.showToast( - activity, - R.string.no_chromecast_support_toast, - Toast.LENGTH_LONG + resultResumeSeriesButton.isVisible = !resume.isMovie + resultResumeSeriesButton.setOnClickListener { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) ) } } - activity?.let { act -> - if (act.isCastApiAvailable()) { - try { - CastButtonFactory.setUpMediaRouteButton(act, media_route_button) - val castContext = CastContext.getSharedInstance(act.applicationContext) - media_route_button?.isGone = - castContext.castState == CastState.NO_DEVICES_AVAILABLE - // this shit leaks for some reason - //castContext.addCastStateListener { state -> - // media_route_button?.isGone = state == CastState.NO_DEVICES_AVAILABLE - //} - } catch (e: Exception) { - logError(e) - } + } + + observeNullable(viewModel.subscribeStatus) { isSubscribed -> + binding?.resultSubscribe?.isVisible = isSubscribed != null + if (isSubscribed == null) return@observeNullable + + val drawable = if (isSubscribed) { + R.drawable.ic_baseline_notifications_active_24 + } else { + R.drawable.baseline_notifications_none_24 + } + + binding?.resultSubscribe?.setImageResource(drawable) + } + + observeNullable(viewModel.favoriteStatus) { isFavorite -> + binding?.resultFavorite?.isVisible = isFavorite != null + if (isFavorite == null) return@observeNullable + + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + + binding?.resultFavorite?.setImageResource(drawable) + } + + observe(viewModel.trailers) { trailers -> + setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! + } + + observeNullable(viewModel.episodes) { episodes -> + resultBinding?.apply { + // no failure? + resultEpisodeLoading.isVisible = episodes is Resource.Loading + resultEpisodes.isVisible = episodes is Resource.Success + if (episodes is Resource.Success) { + (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) } } } - observe(viewModel.episodesCountText) { count -> - result_episodes_text.setText(count) - } + observeNullable(viewModel.movie) { data -> + resultBinding?.apply { + resultPlayMovie.isVisible = data is Resource.Success + downloadButton.isVisible = + data is Resource.Success && viewModel.currentRepo?.api?.hasDownloadSupport == true - observe(viewModel.selectPopup) { popup -> - when (popup) { - is Some.Success -> { - popupDialog?.dismissSafe(activity) - - popupDialog = activity?.let { act -> - val pop = popup.value - val options = pop.getOptions(act) - val title = pop.getTitle(act) - - act.showBottomDialogInstant( - options, title, { - popupDialog = null - pop.callback(null) - }, { - popupDialog = null - pop.callback(it) - } + (data as? Resource.Success)?.value?.let { (text, ep) -> + resultPlayMovie.setText(text) + resultPlayMovie.setOnClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) ) } - } - is Some.None -> { - popupDialog?.dismissSafe(activity) - popupDialog = null - } - } - } - - observe(viewModel.loadedLinks) { load -> - when (load) { - is Some.Success -> { - if (loadingDialog?.isShowing != true) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null + resultPlayMovie.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) + ) + return@setOnLongClickListener true } - loadingDialog = loadingDialog ?: context?.let { ctx -> - val builder = - BottomSheetDialog(ctx) - builder.setContentView(R.layout.bottom_loading) - builder.setOnDismissListener { - loadingDialog = null - viewModel.cancelLinks() + downloadButton.setDefaultClickListener( + VideoDownloadHelper.DownloadEpisodeCached( + name = ep.name, + poster = ep.poster, + episode = 0, + season = null, + id = ep.id, + parentId = ep.id, + rating = null, + description = null, + cacheTime = System.currentTimeMillis(), + ), + null + ) { click -> + context?.let { openBatteryOptimizationSettings(it) } + + when (click.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + viewModel.handleAction( + EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) + ) + } + + DOWNLOAD_ACTION_LONG_CLICK -> { + viewModel.handleAction( + EpisodeClickEvent( + ACTION_DOWNLOAD_MIRROR, + ep + ) + ) + } + + else -> DownloadButtonSetup.handleDownloadClick(click) } - //builder.setOnCancelListener { - // it?.dismiss() - //} - builder.setCanceledOnTouchOutside(true) - - builder.show() - - builder } } - is Some.None -> { - loadingDialog?.dismissSafe(activity) - loadingDialog = null - } } } - observe(viewModel.selectedSeason) { text -> - result_season_button.setText(text) + observe(viewModel.page) { data -> + if (data == null) return@observe + resultBinding?.apply { + PanelsChildGestureRegionObserver.Provider.get().apply { + register(resultCastItems) + } + (data as? Resource.Success)?.value?.let { d -> + resultVpn.setText(d.vpnText) + resultInfo.setText(d.metaText) + resultNoEpisodes.setText(d.noEpisodesFoundText) + resultTitle.setText(d.titleText) + resultMetaSite.setText(d.apiName) + resultMetaType.setText(d.typeText) + resultMetaYear.setText(d.yearText) + resultMetaDuration.setText(d.durationText) + resultMetaRating.setText(d.ratingText) + resultMetaStatus.setText(d.onGoingText) + resultMetaContentRating.setText(d.contentRatingText) + resultCastText.setText(d.actorsText) + resultNextAiring.setText(d.nextAiringEpisode) + resultNextAiringTime.setText(d.nextAiringDate) + resultPoster.setImage(d.posterImage) + resultPosterBackground.setImage(d.posterBackgroundImage) - selectSeason = - (if (text is Some.Success) text.value else null)?.asStringNull(result_season_button?.context) - // If the season button is visible the result season button will be next focus down - if (result_season_button?.isVisible == true) - if (result_resume_parent?.isVisible == true) - setFocusUpAndDown(result_resume_series_button, result_season_button) - //else - // setFocusUpAndDown(result_bookmark_button, result_season_button) + var isExpanded = false + resultDescription.apply { + setTextHtml(d.plotText) + setOnClickListener { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 10 + } + } + + populateChips(resultTag, d.tags) + + resultComingSoon.isVisible = d.comingSoon + resultDataHolder.isGone = d.comingSoon + + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.updateList(d.actors ?: emptyList()) + + if (d.contentRatingText == null) { + // If there is no rating to display, we don't want an empty gap + resultMetaContentRating.width = 0 + } + + if (syncModel.addSyncs(d.syncData)) { + syncModel.updateMetaAndUser() + syncModel.updateSynced() + } else { + syncModel.addFromUrl(d.url) + } + + binding?.apply { + resultSearch.setOnClickListener { + QuickSearchFragment.pushSearch(activity, d.title) + } + + resultShare.setOnClickListener { + try { + val i = Intent(Intent.ACTION_SEND) + i.type = "text/plain" + i.putExtra(Intent.EXTRA_SUBJECT, d.title) + i.putExtra(Intent.EXTRA_TEXT, d.url) + startActivity(Intent.createChooser(i, d.title)) + } catch (e: Exception) { + logError(e) + } + } + + setUrl(d.url) + resultBookmarkFab.apply { + isVisible = true + extend() + } + } + } + + (data as? Resource.Failure)?.let { data -> + resultErrorText.text = storedData.url.plus("\n") + data.errorString + } + + binding?.resultBookmarkFab?.isVisible = data is Resource.Success + resultFinishLoading.isVisible = data is Resource.Success + + resultLoading.isVisible = data is Resource.Loading + + resultLoadingError.isVisible = data is Resource.Failure + resultErrorText.isVisible = data is Resource.Failure + resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure + + resultTitle.setOnLongClickListener { + clipboardHelper(txt(R.string.title), resultTitle.text) + true + } + } } - observe(viewModel.selectedDubStatus) { status -> - result_dub_select?.setText(status) - - if (result_dub_select?.isVisible == true) - if (result_season_button?.isVisible != true && result_episode_select?.isVisible != true) { - if (result_resume_parent?.isVisible == true) - setFocusUpAndDown(result_resume_series_button, result_dub_select) - //else - // setFocusUpAndDown(result_bookmark_button, result_dub_select) - } + observeNullable(viewModel.episodesCountText) { count -> + resultBinding?.resultEpisodesText.setText(count) } - observe(viewModel.selectedRange) { range -> - result_episode_select.setText(range) - // If Season button is invisible then the bookmark button next focus is episode select - if (result_episode_select?.isVisible == true) - if (result_season_button?.isVisible != true) { - if (result_resume_parent?.isVisible == true) - setFocusUpAndDown(result_resume_series_button, result_episode_select) - //else - // setFocusUpAndDown(result_bookmark_button, result_episode_select) + observeNullable(viewModel.selectPopup) { popup -> + if (popup == null) { + popupDialog?.dismissSafe(activity) + popupDialog = null + return@observeNullable + } + popupDialog?.dismissSafe(activity) + + popupDialog = activity?.let { act -> + val options = popup.getOptions(act) + val title = popup.getTitle(act) + + act.showBottomDialogInstant( + options, title, { + popupDialog = null + popup.callback(null) + }, { + popupDialog = null + popup.callback(it) + } + ) + } + } + + observe(syncModel.synced) { list -> + syncBinding?.resultSyncNames?.text = + list.filter { it.isSynced && it.hasAccount }.joinToString { it.name } + + val newList = list.filter { it.isSynced && it.hasAccount } + + binding?.resultMiniSync?.isVisible = newList.isNotEmpty() + (binding?.resultMiniSync?.adapter as? ImageAdapter)?.updateList(newList.mapNotNull { it.icon }) + } + + + var currentSyncProgress = 0 + fun setSyncMaxEpisodes(totalEpisodes: Int?) { + syncBinding?.resultSyncEpisodes?.max = (totalEpisodes ?: 0) * 1000 + + normalSafeApiCall { + val ctx = syncBinding?.resultSyncEpisodes?.context + syncBinding?.resultSyncMaxEpisodes?.text = + totalEpisodes?.let { episodes -> + ctx?.getString(R.string.sync_total_episodes_some)?.format(episodes) + } ?: run { + ctx?.getString(R.string.sync_total_episodes_none) + } + } + } + observe(syncModel.metadata) { meta -> + when (meta) { + is Resource.Success -> { + val d = meta.value + syncBinding?.resultSyncEpisodes?.progress = currentSyncProgress * 1000 + setSyncMaxEpisodes(d.totalEpisodes) + + viewModel.setMeta(d, syncModel.getSyncs()) } + + is Resource.Loading -> { + syncBinding?.resultSyncMaxEpisodes?.text = + syncBinding?.resultSyncMaxEpisodes?.context?.getString(R.string.sync_total_episodes_none) + } + + else -> {} + } + } + + + observe(syncModel.userData) { status -> + var closed = false + syncBinding?.apply { + when (status) { + is Resource.Failure -> { + resultSyncLoadingShimmer.stopShimmer() + resultSyncLoadingShimmer.isVisible = false + resultSyncHolder.isVisible = false + closed = true + } + + is Resource.Loading -> { + resultSyncLoadingShimmer.startShimmer() + resultSyncLoadingShimmer.isVisible = true + resultSyncHolder.isVisible = false + } + + is Resource.Success -> { + resultSyncLoadingShimmer.stopShimmer() + resultSyncLoadingShimmer.isVisible = false + resultSyncHolder.isVisible = true + + val d = status.value + resultSyncRating.value = d.score?.toFloat() ?: 0.0f + resultSyncCheck.setItemChecked(d.status.internalId + 1, true) + val watchedEpisodes = d.watchedEpisodes ?: 0 + currentSyncProgress = watchedEpisodes + + d.maxEpisodes?.let { + // don't directly call it because we don't want to override metadata observe + setSyncMaxEpisodes(it) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + resultSyncEpisodes.setProgress(watchedEpisodes * 1000, true) + } else { + resultSyncEpisodes.progress = watchedEpisodes * 1000 + } + resultSyncCurrentEpisodes.text = + Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) + normalSafeApiCall { // format might fail + context?.getString(R.string.sync_score_format)?.format(d.score ?: 0) + ?.let { + resultSyncScoreText.text = it + } + } + } + + null -> { + closed = false + } + } + } + binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + } + observe(viewModel.recommendations) { recommendations -> + setRecommendations(recommendations, null) + } + context?.let { ctx -> + val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + /* + -1 -> None + 0 -> Watching + 1 -> Completed + 2 -> OnHold + 3 -> Dropped + 4 -> PlanToWatch + 5 -> ReWatching + */ + val items = listOf( + R.string.none, + R.string.type_watching, + R.string.type_completed, + R.string.type_on_hold, + R.string.type_dropped, + R.string.type_plan_to_watch, + R.string.type_re_watching + ).map { ctx.getString(it) } + arrayAdapter.addAll(items) + syncBinding?.apply { + resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE + resultSyncCheck.adapter = arrayAdapter + UIHelper.setListViewHeightBasedOnItems(resultSyncCheck) + + resultSyncCheck.setOnItemClickListener { _, _, which, _ -> + syncModel.setStatus(which - 1) + } + + resultSyncRating.addOnChangeListener { _, value, _ -> + syncModel.setScore(value.toInt()) + } + + resultSyncAddEpisode.setOnClickListener { + syncModel.setEpisodesDelta(1) + } + + resultSyncSubEpisode.setOnClickListener { + syncModel.setEpisodesDelta(-1) + } + + resultSyncCurrentEpisodes.doOnTextChanged { text, _, before, count -> + if (count == before) return@doOnTextChanged + text?.toString()?.toIntOrNull()?.let { ep -> + syncModel.setEpisodes(ep) + } + } + } + } + + syncBinding?.resultSyncSetScore?.setOnClickListener { + syncModel.publishUserData() + } + + observe(viewModel.watchStatus) { watchType -> + binding?.resultBookmarkFab?.apply { + setText(watchType.stringRes) + if (watchType == WatchType.NONE) { + context?.colorFromAttribute(R.attr.white) + } else { + context?.colorFromAttribute(R.attr.colorPrimary) + }?.let { + val colorState = ColorStateList.valueOf(it) + iconTint = colorState + setTextColor(colorState) + } + + setOnClickListener { fab -> + activity?.showBottomDialog( + WatchType.entries.map { fab.context.getString(it.stringRes) }.toList(), + watchType.ordinal, + fab.context.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + viewModel.updateWatchStatus(WatchType.entries[it], context) + } + } + } + } + + + observeNullable(viewModel.loadedLinks) { load -> + if (load == null) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + return@observeNullable + } + if (loadingDialog?.isShowing != true) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + } + loadingDialog = loadingDialog ?: context?.let { ctx -> + val builder = + BottomSheetDialog(ctx) + builder.setContentView(R.layout.bottom_loading) + builder.setOnDismissListener { + loadingDialog = null + viewModel.cancelLinks() + } + //builder.setOnCancelListener { + // it?.dismiss() + //} + builder.setCanceledOnTouchOutside(true) + builder.show() + builder + } + } + + observeNullable(viewModel.selectedSeason) { text -> + resultBinding?.apply { + resultSeasonButton.setText(text) + + selectSeason = + text?.asStringNull(resultSeasonButton.context) + // If the season button is visible the result season button will be next focus down + if (resultSeasonButton.isVisible && resultResumeParent.isVisible) { + setFocusUpAndDown(resultResumeSeriesButton, resultSeasonButton) + } + } + } + + observeNullable(viewModel.selectedDubStatus) { status -> + resultBinding?.apply { + resultDubSelect.setText(status) + + if (resultDubSelect.isVisible && !resultSeasonButton.isVisible && !resultEpisodeSelect.isVisible && resultResumeParent.isVisible) { + setFocusUpAndDown(resultResumeSeriesButton, resultDubSelect) + } + } + } + observeNullable(viewModel.selectedRange) { range -> + resultBinding?.apply { + resultEpisodeSelect.setText(range) + + selectEpisodeRange = range?.asStringNull(resultEpisodeSelect.context) + // If Season button is invisible then the bookmark button next focus is episode select + if (resultEpisodeSelect.isVisible && !resultSeasonButton.isVisible && resultResumeParent.isVisible) { + setFocusUpAndDown(resultResumeSeriesButton, resultEpisodeSelect) + } + } } // val preferDub = context?.getApiDubstatusSettings()?.all { it == DubStatus.Dubbed } == true observe(viewModel.dubSubSelections) { range -> - result_dub_select.setOnClickListener { view -> + resultBinding?.resultDubSelect?.setOnClickListener { view -> view?.context?.let { ctx -> view.popupMenuNoIconsAndNoStringRes(range .mapNotNull { (text, status) -> @@ -384,23 +1068,26 @@ class ResultFragmentPhone : ResultFragment() { text?.asStringNull(ctx) ?: return@mapNotNull null ) }) { - viewModel.changeDubStatus(DubStatus.values()[itemId]) + viewModel.changeDubStatus(DubStatus.entries[itemId]) } } } } observe(viewModel.rangeSelections) { range -> - result_episode_select?.setOnClickListener { view -> + resultBinding?.resultEpisodeSelect?.setOnClickListener { view -> view?.context?.let { ctx -> val names = range .mapNotNull { (text, r) -> r to (text?.asStringNull(ctx) ?: return@mapNotNull null) } - view.popupMenuNoIconsAndNoStringRes(names.mapIndexed { index, (_, name) -> - index to name - }) { + activity?.showDialog( + names.map { it.second }, + names.indexOfFirst { it.second == selectEpisodeRange }, + "", + false, + {}) { itemId -> viewModel.changeRange(names[itemId].first) } } @@ -408,7 +1095,7 @@ class ResultFragmentPhone : ResultFragment() { } observe(viewModel.seasonSelections) { seasonList -> - result_season_button?.setOnClickListener { view -> + resultBinding?.resultSeasonButton?.setOnClickListener { view -> view?.context?.let { ctx -> val names = seasonList @@ -438,54 +1125,59 @@ class ResultFragmentPhone : ResultFragment() { override fun onPause() { super.onPause() - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) } - override fun onGestureRegionsUpdate(gestureRegions: List) { - result_overlapping_panels?.setChildGestureRegions(gestureRegions) - } - - override fun setRecommendations(rec: List?, validApiName: String?) { + private fun setRecommendations(rec: List?, validApiName: String?) { val isInvalid = rec.isNullOrEmpty() - result_recommendations?.isGone = isInvalid - result_recommendations_btt?.isGone = isInvalid - result_recommendations_btt?.setOnClickListener { - val nextFocusDown = if (result_overlapping_panels?.getSelectedPanel()?.ordinal == 1) { - result_overlapping_panels?.openEndPanel() - R.id.result_recommendations - } else { - result_overlapping_panels?.closePanels() - R.id.result_description - } - - result_recommendations_btt?.nextFocusDownId = nextFocusDown - result_search?.nextFocusDownId = nextFocusDown - result_open_in_browser?.nextFocusDownId = nextFocusDown - result_share?.nextFocusDownId = nextFocusDown - } - result_overlapping_panels?.setEndPanelLockState(if (isInvalid) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - rec?.map { it.apiName }?.distinct()?.let { apiNames -> - // very dirty selection - result_recommendations_filter_button?.isVisible = apiNames.size > 1 - result_recommendations_filter_button?.text = matchAgainst - result_recommendations_filter_button?.setOnClickListener { _ -> - activity?.showBottomDialog( - apiNames, - apiNames.indexOf(matchAgainst), - getString(R.string.home_change_provider_img_des), false, {} - ) { - setRecommendations(rec, apiNames[it]) + + recommendationBinding?.apply { + root.isGone = isInvalid + root.post { + rec?.let { list -> + (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) } } - } ?: run { - result_recommendations_filter_button?.isVisible = false } - result_recommendations?.post { - rec?.let { list -> - (result_recommendations?.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) + binding?.apply { + resultRecommendationsBtt.isGone = isInvalid + resultRecommendationsBtt.setOnClickListener { + val nextFocusDown = if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openEndPanel() + R.id.result_recommendations + } else { + resultOverlappingPanels.closePanels() + R.id.result_description + } + resultBinding?.apply { + resultRecommendationsBtt.nextFocusDownId = nextFocusDown + resultSearch.nextFocusDownId = nextFocusDown + resultOpenInBrowser.nextFocusDownId = nextFocusDown + resultShare.nextFocusDownId = nextFocusDown + } + } + resultOverlappingPanels.setEndPanelLockState(if (isInvalid) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + + rec?.map { it.apiName }?.distinct()?.let { apiNames -> + // very dirty selection + recommendationBinding?.resultRecommendationsFilterButton?.apply { + isVisible = apiNames.size > 1 + text = matchAgainst + setOnClickListener { _ -> + activity?.showBottomDialog( + apiNames, + apiNames.indexOf(matchAgainst), + getString(R.string.home_change_provider_img_des), false, {} + ) { + setRecommendations(rec, apiNames[it]) + } + } + } + } ?: run { + recommendationBinding?.resultRecommendationsFilterButton?.isVisible = false } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index 2bd8ff0f..1878f0b8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -1,33 +1,88 @@ package com.lagradost.cloudstream3.ui.result +import android.animation.Animator +import android.annotation.SuppressLint import android.app.Dialog import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.widget.NestedScrollView +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.APIHolder.updateHasTrailers +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.mvvm.ResourceSome -import com.lagradost.cloudstream3.mvvm.Some +import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding +import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.ExtractorLinkGenerator import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE +import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData +import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper -import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl +import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache +import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.fragment_result_tv.* +import com.lagradost.cloudstream3.utils.UIHelper.setImage -class ResultFragmentTv : ResultFragment() { - override val resultLayout = R.layout.fragment_result_tv +class ResultFragmentTv : Fragment() { + private lateinit var viewModel: ResultViewModel2 + private var binding: FragmentResultTvBinding? = null + + override fun onDestroyView() { + binding = null + updateUIEvent -= ::updateUI + super.onDestroyView() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + viewModel = + ViewModelProvider(this)[ResultViewModel2::class.java] + viewModel.EPISODE_RANGE_SIZE = 50 + updateUIEvent += ::updateUI + + val localBinding = FragmentResultTvBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + } + + private fun updateUI(id: Int?) { + viewModel.reloadEpisodes() + } private var currentRecommendations: List = emptyList() @@ -36,12 +91,15 @@ class ResultFragmentTv : ResultFragment() { is EpisodeRange -> { viewModel.changeRange(data) } + is Int -> { viewModel.changeSeason(data) } + is DubStatus -> { viewModel.changeDubStatus(data) } + is String -> { setRecommendations(currentRecommendations, data) } @@ -63,178 +121,800 @@ class ResultFragmentTv : ResultFragment() { } } - private fun hasNoFocus(): Boolean { - val focus = activity?.currentFocus - if (focus == null || !focus.isVisible) return true - return focus == this.result_root +// private fun hasNoFocus(): Boolean { +// val focus = activity?.currentFocus +// if (focus == null || !focus.isVisible) return true +// return focus == binding?.resultRoot +// } + + /** + * Force focus any play button. + * Note that this will steal any focus if the episode loading is too slow (unlikely). + */ + private fun focusPlayButton() { + binding?.resultPlayMovieButton?.requestFocus() + binding?.resultPlaySeriesButton?.requestFocus() + binding?.resultResumeSeriesButton?.requestFocus() } - override fun updateEpisodes(episodes: ResourceSome>) { - super.updateEpisodes(episodes) - if (episodes is ResourceSome.Success && hasNoFocus()) { - result_episodes?.requestFocus() - } - } - - override fun updateMovie(data: ResourceSome>) { - super.updateMovie(data) - if (data is ResourceSome.Success && hasNoFocus()) { - result_play_movie?.requestFocus() - } - } - - override fun setTrailers(trailers: List?) { - context?.updateHasTrailers() - if (!LoadResponse.isTrailersEnabled) return - - result_play_trailer?.isGone = trailers.isNullOrEmpty() - result_play_trailer?.setOnClickListener { - if (trailers.isNullOrEmpty()) return@setOnClickListener - activity.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - ExtractorLinkGenerator( - trailers, - emptyList() - ) - ) - ) - } - } - - override fun setRecommendations(rec: List?, validApiName: String?) { + private fun setRecommendations(rec: List?, validApiName: String?) { currentRecommendations = rec ?: emptyList() val isInvalid = rec.isNullOrEmpty() - result_recommendations?.isGone = isInvalid - result_recommendations_holder?.isGone = isInvalid - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (result_recommendations?.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst } - ?: emptyList()) + binding?.apply { + resultRecommendationsList.isGone = isInvalid + resultRecommendationsHolder.isGone = isInvalid + val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName + (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst } + ?: emptyList()) - rec?.map { it.apiName }?.distinct()?.let { apiNames -> - // very dirty selection - result_recommendations_filter_selection?.isVisible = apiNames.size > 1 - result_recommendations_filter_selection?.update(apiNames.map { txt(it) to it }) - result_recommendations_filter_selection?.select(apiNames.indexOf(matchAgainst)) - } ?: run { - result_recommendations_filter_selection?.isVisible = false + rec?.map { it.apiName }?.distinct()?.let { apiNames -> + // very dirty selection + resultRecommendationsFilterSelection.isVisible = apiNames.size > 1 + resultRecommendationsFilterSelection.update(apiNames.map { txt(it) to it }) + resultRecommendationsFilterSelection.select(apiNames.indexOf(matchAgainst)) + } ?: run { + resultRecommendationsFilterSelection.isVisible = false + } } } var loadingDialog: Dialog? = null var popupDialog: Dialog? = null + + private fun reloadViewModel(forceReload: Boolean) { + if (!viewModel.hasLoaded() || forceReload) { + val storedData = getStoredData() ?: return + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + } + } + + override fun onResume() { + activity?.let { + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } + afterPluginsLoadedEvent += ::reloadViewModel + super.onResume() + } + + override fun onStop() { + afterPluginsLoadedEvent -= ::reloadViewModel + super.onStop() + } + + private fun View.fade(turnVisible: Boolean) { + if (turnVisible) { + isVisible = true + } + + this.animate().alpha(if (turnVisible) 0.97f else 0.0f).apply { + duration = 200 + interpolator = DecelerateInterpolator() + setListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) { + } + + override fun onAnimationEnd(animation: Animator) { + this@fade.isVisible = turnVisible + } + + override fun onAnimationCancel(animation: Animator) { + } + + override fun onAnimationRepeat(animation: Animator) { + } + }) + } + this.animate().translationX(if (turnVisible) 0f else if (isRtl()) -100.0f else 100f).apply { + duration = 200 + interpolator = DecelerateInterpolator() + } + } + + private fun toggleEpisodes(show: Boolean) { + binding?.apply { + episodesShadow.fade(show) + episodeHolderTv.fade(show) + if (episodesShadow.isRtl()) { + episodesShadowBackground.scaleX = -1f + } else { + episodesShadowBackground.scaleX = 1f + } + } + } + + @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - result_episodes?.layoutManager = - //LinearListLayout(result_episodes ?: return, result_episodes?.context).apply { - LinearListLayout(result_episodes?.context).apply { + // ===== setup ===== + val storedData = getStoredData() ?: return + activity?.window?.decorView?.clearFocus() + activity?.loadCache() + hideKeyboard() + if (storedData.restart || !viewModel.hasLoaded()) + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + // ===== ===== ===== + var comingSoon = false + + binding?.apply { + //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f + + // parallax on background + resultFinishLoading.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { view, _, scrollY, _, oldScrollY -> + backgroundPosterHolder.translationY = -scrollY.toFloat() * 0.8f + }) + + redirectToPlay.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) return@setOnFocusChangeListener + toggleEpisodes(false) + + binding?.apply { + val views = listOf( + resultPlayMovieButton, + resultPlaySeriesButton, + resultResumeSeriesButton, + resultPlayTrailerButton, + resultBookmarkButton, + resultFavoriteButton, + resultSubscribeButton, + resultSearchButton + ) + for (requestView in views) { + if (!requestView.isVisible) continue + if (requestView.requestFocus()) break + } + } + } + + redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) return@setOnFocusChangeListener + toggleEpisodes(true) + binding?.apply { + val views = listOf( + resultDubSelection, + resultSeasonSelection, + resultRangeSelection, + resultEpisodes, + resultPlayTrailerButton, + ) + for (requestView in views) { + if (!requestView.isShown) continue + if (requestView.requestFocus()) break // View.FOCUS_RIGHT + } + } + } + + mapOf( + resultPlayMovieButton to resultPlayMovieText, + resultPlaySeriesButton to resultPlaySeriesText, + resultResumeSeriesButton to resultResumeSeriesText, + resultPlayTrailerButton to resultPlayTrailerText, + resultBookmarkButton to resultBookmarkText, + resultFavoriteButton to resultFavoriteText, + resultSubscribeButton to resultSubscribeText, + resultSearchButton to resultSearchText, + resultEpisodesShowButton to resultEpisodesShowText + ).forEach { (button , text) -> + + button.setOnFocusChangeListener { view, hasFocus -> + if (!hasFocus) { + text.isSelected = false + if (view.id == R.id.result_episodes_show_button) toggleEpisodes(false) + return@setOnFocusChangeListener + } + + text.isSelected = true + if (button.tag == context?.getString(R.string.tv_no_focus_tag)){ + resultFinishLoading.scrollTo(0,0) + } + when (button.id) { + R.id.result_episodes_show_button -> { + toggleEpisodes(true) + } + else -> { + toggleEpisodes(false) + } + } + } + } + + resultEpisodesShowButton.setOnClickListener { + // toggle, to make it more touch accessible just in case someone thinks that a + // tv layout is better but is using a touch device + toggleEpisodes(!episodeHolderTv.isVisible) + } + + resultEpisodes.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + resultDubSelection.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + resultRangeSelection.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + resultSeasonSelection.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + + /*.layoutManager = + LinearListLayout(resultEpisodes.context, resultEpisodes.isRtl()).apply { + setVertical() + }*/ + + resultReloadConnectionerror.setOnClickListener { + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + + } + + resultMetaSite.isFocusable = false + + resultSeasonSelection.setAdapter() + resultRangeSelection.setAdapter() + resultDubSelection.setAdapter() + resultRecommendationsFilterSelection.setAdapter() + + resultCastItems.setOnFocusChangeListener { _, hasFocus -> + // Always escape focus + if (hasFocus) binding?.resultBookmarkButton?.requestFocus() + } + //resultBack.setOnClickListener { + // activity?.popCurrentPage() + //} + + resultRecommendationsList.spanCount = 8 + resultRecommendationsList.adapter = + SearchAdapter( + ArrayList(), + resultRecommendationsList, + ) { callback -> + if (callback.action == SEARCH_ACTION_FOCUSED) + toggleEpisodes(false) + else + SearchHelper.handleSearchClickCallback(callback) + } + + resultEpisodes.adapter = + EpisodeAdapter( + false, + { episodeClick -> + viewModel.handleAction(episodeClick) + }, + { downloadClickEvent -> + DownloadButtonSetup.handleDownloadClick(downloadClickEvent) + } + ) + + resultCastItems.layoutManager = object : LinearListLayout(view.context) { + + override fun onRequestChildFocus( + parent: RecyclerView, + state: RecyclerView.State, + child: View, + focused: View? + ): Boolean { + // Make the cast always focus the first visible item when focused + // from somewhere else. Otherwise it jumps to the last item. + return if (parent.focusedChild == null) { + scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) + true + } else { + super.onRequestChildFocus(parent, state, child, focused) + } + } + }.apply { setHorizontal() } - (result_episodes?.adapter as EpisodeAdapter?)?.apply { - layout = R.layout.result_episode_both_tv + + val aboveCast = listOf( + binding?.resultEpisodesShow, + binding?.resultBookmark, + binding?.resultFavorite, + binding?.resultSubscribe, + ).firstOrNull { + it?.isVisible == true + } + resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { + toggleEpisodes(false) + } } - //result_episodes?.setMaxViewPoolSize(0, Int.MAX_VALUE) - result_season_selection.setAdapter() - result_range_selection.setAdapter() - result_dub_selection.setAdapter() - result_recommendations_filter_selection.setAdapter() + observeNullable(viewModel.resumeWatching) { resume -> + binding?.apply { - observe(viewModel.selectPopup) { popup -> - when (popup) { - is Some.Success -> { - popupDialog?.dismissSafe(activity) + if (resume == null) { + return@observeNullable + } + resultResumeSeries.isVisible = true + resultPlayMovie.isVisible = false + resultPlaySeries.isVisible = false - popupDialog = activity?.let { act -> - val pop = popup.value - val options = pop.getOptions(act) - val title = pop.getTitle(act) + // show progress no matter if series or movie + resume.progress?.let { progress -> + resultResumeSeriesProgressText.setText(progress.progressLeft) + resultResumeSeriesProgress.apply { + isVisible = true + this.max = progress.maxProgress + this.progress = progress.progress + } + resultResumeProgressHolder.isVisible = true + } ?: run { + resultResumeProgressHolder.isVisible = false + } - act.showBottomDialogInstant( - options, title, { - popupDialog = null - pop.callback(null) - }, { - popupDialog = null - pop.callback(it) - } + focusPlayButton() + // Stops last button right focus if it is a movie + if (resume.isMovie) + resultSearchButton.nextFocusRightId = R.id.result_search_Button + + resultResumeSeriesText.text = + when { + resume.isMovie -> context?.getString(R.string.resume) + resume.result.season != null -> + "${getString(R.string.season_short)}${resume.result.season}:${getString(R.string.episode_short)}${resume.result.episode}" + else -> "${getString(R.string.episode)} ${resume.result.episode}" + } + + resultResumeSeriesButton.setOnClickListener { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) + } + + resultResumeSeriesButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, resume.result) + ) + return@setOnLongClickListener true + } + + } + } + + observe(viewModel.trailers) { trailersLinks -> + context?.updateHasTrailers() + if (!LoadResponse.isTrailersEnabled) return@observe + val trailers = trailersLinks.flatMap { it.mirros } + binding?.apply { + resultPlayTrailer.isGone = trailers.isEmpty() + resultPlayTrailerButton.setOnClickListener { + if (trailers.isEmpty()) return@setOnClickListener + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + ExtractorLinkGenerator( + trailers, + emptyList() + ) + ) + ) + } + } + } + + observe(viewModel.watchStatus) { watchType -> + binding?.apply { + resultBookmarkText.setText(watchType.stringRes) + + resultBookmarkButton.apply { + + val drawable = if (watchType.stringRes == R.string.type_none) { + R.drawable.outline_bookmark_add_24 + } else { + R.drawable.ic_baseline_bookmark_24 + } + setIconResource(drawable) + + setOnClickListener { view -> + activity?.showBottomDialog( + WatchType.entries.map { view.context.getString(it.stringRes) }.toList(), + watchType.ordinal, + view.context.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + viewModel.updateWatchStatus(WatchType.entries[it], context) + } + } + } + } + } + + observeNullable(viewModel.favoriteStatus) { isFavorite -> + + binding?.resultFavorite?.isVisible = isFavorite != null + + binding?.resultFavoriteButton?.apply { + + if (isFavorite == null) return@observeNullable + + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + + setIconResource(drawable) + + setOnClickListener { + viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleFavoriteStatus + + val message = if (newStatus) { + R.string.favorite_added + } else { + R.string.favorite_removed + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + CommonActivity.showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + } + + binding?.resultFavoriteText?.apply { + val text = if (isFavorite == true) { + R.string.unfavorite + } else { + R.string.favorite + } + setText(text) + } + } + + observeNullable(viewModel.subscribeStatus) { isSubscribed -> + binding?.resultSubscribe?.isVisible = isSubscribed != null && isLayout(EMULATOR) + binding?.resultSubscribeButton?.apply { + + if (isSubscribed == null) return@observeNullable + + val drawable = if (isSubscribed) { + R.drawable.ic_baseline_notifications_active_24 + } else { + R.drawable.baseline_notifications_none_24 + } + + setIconResource(drawable) + + setOnClickListener { + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus + + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + CommonActivity.showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + + binding?.resultSubscribeText?.apply { + val text = if (isSubscribed) { + R.string.action_unsubscribe + } else { + R.string.action_subscribe + } + setText(text) + } + } + } + + observeNullable(viewModel.movie) { data -> + if (data == null ) { + return@observeNullable + } + + binding?.apply { + + (data as? Resource.Success)?.value?.let { (_, ep) -> + + resultPlayMovieButton.setOnClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) ) } - } - is Some.None -> { - popupDialog?.dismissSafe(activity) - popupDialog = null + resultPlayMovieButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) + ) + return@setOnLongClickListener true + } + + resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone + if (comingSoon) + resultBookmarkButton.requestFocus() + else + resultPlayMovieButton.requestFocus() + + // Stops last button right focus + resultSearchButton.nextFocusRightId = R.id.result_search_Button } } } - observe(viewModel.loadedLinks) { load -> - when (load) { - is Some.Success -> { - if (loadingDialog?.isShowing != true) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null - } - loadingDialog = loadingDialog ?: context?.let { ctx -> - val builder = - BottomSheetDialog(ctx) - builder.setContentView(R.layout.bottom_loading) - builder.setOnDismissListener { - loadingDialog = null - viewModel.cancelLinks() - } - //builder.setOnCancelListener { - // it?.dismiss() - //} - builder.setCanceledOnTouchOutside(true) + observeNullable(viewModel.selectPopup) { popup -> + if (popup == null) { + popupDialog?.dismissSafe(activity) + popupDialog = null + return@observeNullable + } - builder.show() + popupDialog?.dismissSafe(activity) - builder + popupDialog = activity?.let { act -> + val options = popup.getOptions(act) + val title = popup.getTitle(act) + + act.showBottomDialogInstant( + options, title, { + popupDialog = null + popup.callback(null) + }, { + popupDialog = null + popup.callback(it) } - } - is Some.None -> { - loadingDialog?.dismissSafe(activity) + ) + } + } + + observeNullable(viewModel.loadedLinks) { load -> + if (load == null) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + return@observeNullable + } + if (loadingDialog?.isShowing != true) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + } + loadingDialog = loadingDialog ?: context?.let { ctx -> + val builder = BottomSheetDialog(ctx) + builder.setContentView(R.layout.bottom_loading) + builder.setOnDismissListener { loadingDialog = null + viewModel.cancelLinks() } + //builder.setOnCancelListener { + // it?.dismiss() + //} + builder.setCanceledOnTouchOutside(true) + builder.show() + builder } + } - observe(viewModel.episodesCountText) { count -> - result_episodes_text.setText(count) + observeNullable(viewModel.episodesCountText) { count -> + binding?.resultEpisodesText.setText(count) } observe(viewModel.selectedRangeIndex) { selected -> - result_range_selection.select(selected) + binding?.resultRangeSelection.select(selected) } observe(viewModel.selectedSeasonIndex) { selected -> - result_season_selection.select(selected) + binding?.resultSeasonSelection.select(selected) } observe(viewModel.selectedDubStatusIndex) { selected -> - result_dub_selection.select(selected) + binding?.resultDubSelection.select(selected) } observe(viewModel.rangeSelections) { - result_range_selection.update(it) + binding?.resultRangeSelection.update(it) } observe(viewModel.dubSubSelections) { - result_dub_selection.update(it) + binding?.resultDubSelection.update(it) } observe(viewModel.seasonSelections) { - result_season_selection.update(it) + binding?.resultSeasonSelection.update(it) + } + observe(viewModel.recommendations) { recommendations -> + setRecommendations(recommendations, null) } - result_back?.setOnClickListener { - activity?.popCurrentPage() - } - - result_recommendations?.spanCount = 8 - result_recommendations?.adapter = - SearchAdapter( - ArrayList(), - result_recommendations, - ) { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + if (isLayout(TV)) { + observe(viewModel.episodeSynopsis) { description -> + view.context?.let { ctx -> + val builder: AlertDialog.Builder = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + builder.setMessage(description.html()) + .setTitle(R.string.synopsis) + .setOnDismissListener { + viewModel.releaseEpisodeSynopsis() + } + .show() + } } + } + + // Used to request focus the first time the episodes are loaded. + var hasLoadedEpisodesOnce = false + observeNullable(viewModel.episodes) { episodes -> + if (episodes == null) return@observeNullable + + binding?.apply { + + if (comingSoon) + resultBookmarkButton.requestFocus() + + // resultEpisodeLoading.isVisible = episodes is Resource.Loading + if (episodes is Resource.Success) { + + val lastWatchedIndex = episodes.value.indexOfLast { ep -> + ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched + } + + val firstUnwatched = episodes.value.getOrElse(lastWatchedIndex + 1) { episodes.value.firstOrNull() } + + if (firstUnwatched != null) { + resultPlaySeriesText.text = + when { + firstUnwatched.season != null -> + "${getString(R.string.season_short)}${firstUnwatched.season}:${getString(R.string.episode_short)}${firstUnwatched.episode}" + else -> "${getString(R.string.episode)} ${firstUnwatched.episode}" + } + resultPlaySeriesButton.setOnClickListener { + viewModel.handleAction( + EpisodeClickEvent( + ACTION_CLICK_DEFAULT, + firstUnwatched + ) + ) + } + resultPlaySeriesButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, firstUnwatched) + ) + return@setOnLongClickListener true + } + if (!hasLoadedEpisodesOnce) { + hasLoadedEpisodesOnce = true + resultPlaySeries.isVisible = resultResumeSeries.isGone && !comingSoon + resultEpisodesShow.isVisible = true && !comingSoon + resultPlaySeriesButton.requestFocus() + } + } + + + (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) + } + } + } + + observeNullable(viewModel.page) { data -> + if (data == null) return@observeNullable + binding?.apply { + when (data) { + is Resource.Success -> { + val d = data.value + resultVpn.setText(d.vpnText) + resultInfo.setText(d.metaText) + resultNoEpisodes.setText(d.noEpisodesFoundText) + resultTitle.setText(d.titleText) + resultMetaSite.setText(d.apiName) + resultMetaType.setText(d.typeText) + resultMetaYear.setText(d.yearText) + resultMetaDuration.setText(d.durationText) + resultMetaRating.setText(d.ratingText) + resultMetaStatus.setText(d.onGoingText) + resultMetaContentRating.setText(d.contentRatingText) + resultCastText.setText(d.actorsText) + resultNextAiring.setText(d.nextAiringEpisode) + resultNextAiringTime.setText(d.nextAiringDate) + resultPoster.setImage(d.posterImage) + + var isExpanded = false + resultDescription.apply { + setTextHtml(d.plotText) + setOnClickListener { + if (isLayout(EMULATOR)) { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 10 + } else { + view.context?.let { ctx -> + val builder: AlertDialog.Builder = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + builder.setMessage(d.plotText.asString(ctx).html()) + .setTitle(d.plotHeaderText.asString(ctx)) + .show() + } + } + } + } + + val error = listOf( + R.drawable.profile_bg_dark_blue, + R.drawable.profile_bg_blue, + R.drawable.profile_bg_orange, + R.drawable.profile_bg_pink, + R.drawable.profile_bg_purple, + R.drawable.profile_bg_red, + R.drawable.profile_bg_teal + ).random() + //Change poster crop area to 20% from Top + backgroundPoster.cropYCenterOffsetPct = 0.20F + + backgroundPoster.setImage( + d.posterBackgroundImage ?: UiImage.Drawable(error), + radius = 0, + errorImageDrawable = error + ) + comingSoon = d.comingSoon + resultTvComingSoon.isVisible = d.comingSoon + + UIHelper.populateChips(resultTag, d.tags) + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.updateList( + d.actors ?: emptyList() + ) + + if (d.contentRatingText == null) { + // If there is no rating to display, we don't want an empty gap + resultMetaContentRating.width = 0 + } + + resultSearchButton.setOnClickListener { + QuickSearchFragment.pushSearch(activity, d.title) + } + } + + is Resource.Loading -> { + + } + + is Resource.Failure -> { + resultErrorText.text = + storedData.url.plus("\n") + data.errorString + } + } + + resultFinishLoading.isVisible = data is Resource.Success + + resultLoading.isVisible = data is Resource.Loading + + resultLoadingError.isVisible = data is Resource.Failure + //resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index bf47209a..2ab60c2f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,28 +3,23 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration -import android.graphics.Rect import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.core.view.isGone import androidx.core.view.isVisible -import com.discord.panels.PanelsChildGestureRegionObserver +import com.lagradost.cloudstream3.CommonActivity.screenHeight +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.CSPlayerEvent +import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.utils.IOnBackPressed -import kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_result.result_smallscreen_holder -import kotlinx.android.synthetic.main.fragment_result_swipe.* -import kotlinx.android.synthetic.main.fragment_result_tv.* -import kotlinx.android.synthetic.main.fragment_trailer.* -import kotlinx.android.synthetic.main.trailer_custom_layout.* +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback - -open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreenPlayer(), - PanelsChildGestureRegionObserver.GestureRegionsListener, IOnBackPressed { +open class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false @@ -34,13 +29,13 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen const val TAG = "RESULT_TRAILER" } - var playerWidthHeight: Pair? = null + private var playerWidthHeight: Pair? = null override fun nextEpisode() {} override fun prevEpisode() {} - override fun playerPositionChanged(posDur: Pair) {} + override fun playerPositionChanged(position: Long, duration : Long) {} override fun nextMirror() {} @@ -52,6 +47,8 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen private fun fixPlayerSize() { playerWidthHeight?.let { (w, h) -> + if(w <= 0 || h <= 0) return@let + val orientation = context?.resources?.configuration?.orientation ?: return val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { @@ -60,13 +57,13 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen screenHeight } - result_trailer_loading?.isVisible = false - result_smallscreen_holder?.isVisible = !isFullScreenPlayer - result_fullscreen_holder?.isVisible = isFullScreenPlayer + //result_trailer_loading?.isVisible = false + resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer + binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer val to = sw * h / w - player_background?.apply { + resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true layoutParams = FrameLayout.LayoutParams( @@ -75,16 +72,17 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen ) } - player_intro_play?.apply { + playerBinding?.playerIntroPlay?.apply { layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, - result_top_holder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT + resultBinding?.resultTopHolder?.measuredHeight + ?: FrameLayout.LayoutParams.MATCH_PARENT ) } - if (player_intro_play?.isGone == true) { - result_top_holder?.apply { + if (playerBinding?.playerIntroPlay?.isGone == true) { + resultBinding?.resultTopHolder?.apply { val anim = ValueAnimator.ofInt( measuredHeight, @@ -104,8 +102,8 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen } } - override fun playerDimensionsLoaded(widthHeight: Pair) { - playerWidthHeight = widthHeight + override fun playerDimensionsLoaded(width: Int, height : Int) { + playerWidthHeight = width to height fixPlayerSize() } @@ -114,7 +112,7 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen override fun openOnlineSubPicker( context: Context, - imdbId: Long?, + loadResponse: LoadResponse?, dismissCallback: () -> Unit ) { } @@ -125,58 +123,59 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen override fun onTracksInfoChanged() {} override fun exitedPipMode() {} - - override fun onGestureRegionsUpdate(gestureRegions: List) {} - private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen - player_fullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + + playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) if (fullscreen) { enterFullscreen() - result_top_bar?.isVisible = false - result_fullscreen_holder?.isVisible = true - result_main_holder?.isVisible = false - player_background?.let { view -> - (view.parent as ViewGroup?)?.removeView(view) - result_fullscreen_holder?.addView(view) + binding?.apply { + resultTopBar.isVisible = false + resultFullscreenHolder.isVisible = true + resultMainHolder.isVisible = false } - } else { - result_top_bar?.isVisible = true - result_fullscreen_holder?.isVisible = false - result_main_holder?.isVisible = true - player_background?.let { view -> + + resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) - result_smallscreen_holder?.addView(view) + binding?.resultFullscreenHolder?.addView(view) + } + + } else { + binding?.apply { + resultTopBar.isVisible = true + resultFullscreenHolder.isVisible = false + resultMainHolder.isVisible = true + resultBinding?.fragmentTrailer?.playerBackground?.let { view -> + (view.parent as ViewGroup?)?.removeView(view) + resultBinding?.resultSmallscreenHolder?.addView(view) + } } exitFullscreen() } fixPlayerSize() uiReset() + + if (isFullScreenPlayer) { + activity?.attachBackPressedCallback { + updateFullscreen(false) + } + } else detachBackPressedCallback() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - player_fullscreen?.setOnClickListener { + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() - player_intro_play?.setOnClickListener { - player_intro_play?.isGone = true - player.handleEvent(CSPlayerEvent.Play) + playerBinding?.playerIntroPlay?.setOnClickListener { + playerBinding?.playerIntroPlay?.isGone = true + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) updateUIVisibility() fixPlayerSize() } } - - override fun onBackPressed(): Boolean { - return if (isFullScreenPlayer) { - updateFullscreen(false) - false - } else { - true - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 6817af6a..6443a923 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -3,9 +3,13 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity import android.content.* import android.net.Uri +import android.os.Build import android.os.Bundle +import android.text.format.Formatter.formatFileSize import android.util.Log import android.widget.Toast +import androidx.annotation.MainThread +import androidx.appcompat.app.AlertDialog import androidx.core.content.FileProvider import androidx.core.net.toUri import androidx.lifecycle.LiveData @@ -14,51 +18,89 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.getId +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString +import com.lagradost.cloudstream3.MainActivity.Companion.MPV +import com.lagradost.cloudstream3.MainActivity.Companion.MPV_COMPONENT +import com.lagradost.cloudstream3.MainActivity.Companion.MPV_PACKAGE +import com.lagradost.cloudstream3.MainActivity.Companion.VLC +import com.lagradost.cloudstream3.MainActivity.Companion.VLC_COMPONENT +import com.lagradost.cloudstream3.MainActivity.Companion.VLC_PACKAGE +import com.lagradost.cloudstream3.MainActivity.Companion.WEB_VIDEO +import com.lagradost.cloudstream3.MainActivity.Companion.WEB_VIDEO_CAST_PACKAGE import com.lagradost.cloudstream3.metaproviders.SyncRedirector import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.providers.Kitsu +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.IGenerator +import com.lagradost.cloudstream3.ui.player.LoadType import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.isAppInstalled -import com.lagradost.cloudstream3.utils.AppUtils.isConnectedToChromecast +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled +import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions +import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub +import com.lagradost.cloudstream3.utils.DataStoreHelper.getFavoritesData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultSeason import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.getSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DataStoreHelper.removeFavoritesData +import com.lagradost.cloudstream3.utils.DataStoreHelper.removeSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.setBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setDub +import com.lagradost.cloudstream3.utils.DataStoreHelper.setFavoritesData import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultSeason +import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.fcast.FcastManager +import com.lagradost.cloudstream3.utils.fcast.FcastSession +import com.lagradost.cloudstream3.utils.fcast.Opcode +import com.lagradost.cloudstream3.utils.fcast.PlayMessage import kotlinx.coroutines.* import java.io.File import java.util.concurrent.TimeUnit - /** This starts at 1 */ data class EpisodeRange( // used to index data @@ -92,6 +134,7 @@ data class ResultData( val plotText: UiText, val apiName: UiText, val ratingText: UiText?, + val contentRatingText: UiText?, val vpnText: UiText?, val metaText: UiText?, val durationText: UiText?, @@ -105,6 +148,18 @@ data class ResultData( val plotHeaderText: UiText, ) +data class CheckDuplicateData( + val name: String, + val year: Int?, + val syncData: Map? +) + +enum class LibraryListType { + BOOKMARKS, + FAVORITES, + SUBSCRIPTIONS +} + fun txt(status: DubStatus?): UiText? { return txt( when (status) { @@ -142,18 +197,25 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { minute ) } + hours > 0 -> txt( R.string.next_episode_time_hour_format, hours, minute ) + minute > 0 -> txt( R.string.next_episode_time_min_format, minute ) + else -> null }?.also { - nextAiringEpisode = txt(R.string.next_episode_format, airing.episode) + nextAiringEpisode = when (airing.season) { + + null -> txt(R.string.next_episode_format, airing.episode) + else -> txt(R.string.next_season_episode_format, airing.season, airing.episode) + } } } } @@ -202,12 +264,16 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { TvType.Live -> R.string.live_singular TvType.Others -> R.string.other_singular TvType.NSFW -> R.string.nsfw_singular + TvType.Music -> R.string.music_singlar + TvType.AudioBook -> R.string.audio_book_singular + TvType.CustomMedia -> R.string.custom_media_singluar } ), yearText = txt(year?.toString()), apiName = txt(apiName), ratingText = rating?.div(1000f) ?.let { if (it <= 0.1f) null else txt(R.string.rating_format, it) }, + contentRatingText = txt(contentRating), vpnText = txt( when (repo.vpnStatus) { VPNStatus.None -> null @@ -218,8 +284,7 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { metaText = if (repo.providerType == ProviderType.MetaProvider) txt(R.string.provider_info_meta) else null, durationText = if (dur == null || dur <= 0) null else txt( - R.string.duration_format, - dur + secondsToReadable(dur * 60, "0 mins") ), onGoingText = if (this is EpisodeResponse) { txt( @@ -237,6 +302,23 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { ) } +data class ExtractorSubtitleLink( + val name: String, + override val url: String, + override val referer: String, + override val headers: Map = mapOf() +) : IDownloadableMinimum + +fun LoadResponse.getId(): Int { + // this fixes an issue with outdated api as getLoadResponseIdFromUrl might be fucked + return (if (this is ResultViewModel2.LoadResponseFromSearch) this.id else null) + ?: getLoadResponseIdFromUrl(url, apiName) +} + +private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int { + return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "") + .hashCode() +} data class LinkProgress( val linksLoaded: Int, @@ -302,6 +384,7 @@ fun SelectPopup.getOptions(context: Context): List { is SelectPopup.SelectArray -> { this.options.map { it.first.asString(context) } } + is SelectPopup.SelectText -> options.map { it.asString(context) } } } @@ -313,7 +396,7 @@ data class ExtractedTrailerData( class ResultViewModel2 : ViewModel() { private var currentResponse: LoadResponse? = null - + var EPISODE_RANGE_SIZE: Int = 20 fun clear() { currentResponse = null _page.postValue(null) @@ -334,7 +417,7 @@ class ResultViewModel2 : ViewModel() { private var currentIndex: EpisodeIndexer? = null private var currentRange: EpisodeRange? = null private var currentShowFillers: Boolean = false - private var currentRepo: APIRepository? = null + var currentRepo: APIRepository? = null private var currentId: Int? = null private var fillers: Map = emptyMap() private var generator: IGenerator? = null @@ -349,17 +432,17 @@ class ResultViewModel2 : ViewModel() { MutableLiveData(null) val page: LiveData?> = _page - private val _episodes: MutableLiveData>> = - MutableLiveData(ResourceSome.Loading()) - val episodes: LiveData>> = _episodes + private val _episodes: MutableLiveData>?> = + MutableLiveData(Resource.Loading()) + val episodes: LiveData>?> = _episodes - private val _movie: MutableLiveData>> = - MutableLiveData(ResourceSome.None) - val movie: LiveData>> = _movie + private val _movie: MutableLiveData>?> = + MutableLiveData(null) + val movie: LiveData>?> = _movie - private val _episodesCountText: MutableLiveData> = - MutableLiveData(Some.None) - val episodesCountText: LiveData> = _episodesCountText + private val _episodesCountText: MutableLiveData = + MutableLiveData(null) + val episodesCountText: LiveData = _episodesCountText private val _trailers: MutableLiveData> = MutableLiveData(mutableListOf()) @@ -381,16 +464,16 @@ class ResultViewModel2 : ViewModel() { MutableLiveData(emptyList()) val recommendations: LiveData> = _recommendations - private val _selectedRange: MutableLiveData> = - MutableLiveData(Some.None) - val selectedRange: LiveData> = _selectedRange + private val _selectedRange: MutableLiveData = + MutableLiveData(null) + val selectedRange: LiveData = _selectedRange - private val _selectedSeason: MutableLiveData> = - MutableLiveData(Some.None) - val selectedSeason: LiveData> = _selectedSeason + private val _selectedSeason: MutableLiveData = + MutableLiveData(null) + val selectedSeason: LiveData = _selectedSeason - private val _selectedDubStatus: MutableLiveData> = MutableLiveData(Some.None) - val selectedDubStatus: LiveData> = _selectedDubStatus + private val _selectedDubStatus: MutableLiveData = MutableLiveData(null) + val selectedDubStatus: LiveData = _selectedDubStatus private val _selectedRangeIndex: MutableLiveData = MutableLiveData(-1) @@ -403,48 +486,32 @@ class ResultViewModel2 : ViewModel() { private val _selectedDubStatusIndex: MutableLiveData = MutableLiveData(-1) val selectedDubStatusIndex: LiveData = _selectedDubStatusIndex - private val _loadedLinks: MutableLiveData> = MutableLiveData(Some.None) - val loadedLinks: LiveData> = _loadedLinks + private val _loadedLinks: MutableLiveData = MutableLiveData(null) + val loadedLinks: LiveData = _loadedLinks - private val _resumeWatching: MutableLiveData> = - MutableLiveData(Some.None) - val resumeWatching: LiveData> = _resumeWatching + private val _resumeWatching: MutableLiveData = + MutableLiveData(null) + val resumeWatching: LiveData = _resumeWatching private val _episodeSynopsis: MutableLiveData = MutableLiveData(null) val episodeSynopsis: LiveData = _episodeSynopsis + private val _subscribeStatus: MutableLiveData = MutableLiveData(null) + val subscribeStatus: LiveData = _subscribeStatus + + private val _favoriteStatus: MutableLiveData = MutableLiveData(null) + val favoriteStatus: LiveData = _favoriteStatus + companion object { const val TAG = "RVM2" - private const val EPISODE_RANGE_SIZE = 20 - private const val EPISODE_RANGE_OVERLOAD = 30 + //private const val EPISODE_RANGE_SIZE = 20 + //private const val EPISODE_RANGE_OVERLOAD = 30 private fun List?.getSeason(season: Int?): SeasonData? { if (season == null) return null return this?.firstOrNull { it.season == season } } - fun updateWatchStatus(currentResponse: LoadResponse, status: WatchType) { - val currentId = currentResponse.getId() - - DataStoreHelper.setResultWatchState(currentId, status.internalId) - val current = DataStoreHelper.getBookmarkedData(currentId) - val currentTime = System.currentTimeMillis() - DataStoreHelper.setBookmarkedData( - currentId, - DataStoreHelper.BookmarkedData( - currentId, - current?.bookmarkedTime ?: currentTime, - currentTime, - currentResponse.name, - currentResponse.url, - currentResponse.apiName, - currentResponse.type, - currentResponse.posterUrl, - currentResponse.year - ) - ) - } - private fun filterName(name: String?): String? { if (name == null) return null Regex("[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { @@ -461,12 +528,16 @@ class ResultViewModel2 : ViewModel() { ) ) - private fun getRanges(allEpisodes: Map>): Map> { + private fun getRanges( + allEpisodes: Map>, + EPISODE_RANGE_SIZE: Int + ): Map> { return allEpisodes.keys.mapNotNull { index -> val episodes = allEpisodes[index] ?: return@mapNotNull null // this should never happened // fast case + val EPISODE_RANGE_OVERLOAD = EPISODE_RANGE_SIZE + 10 if (episodes.size <= EPISODE_RANGE_OVERLOAD) { return@mapNotNull index to listOf( EpisodeRange( @@ -497,7 +568,8 @@ class ResultViewModel2 : ViewModel() { val episodeNumber = episodes[currentIndex].episode if (episodeNumber < currentMin) { currentMin = episodeNumber - } else if (episodeNumber > currentMax) { + } + if (episodeNumber > currentMax) { currentMax = episodeNumber } ++currentIndex @@ -571,12 +643,10 @@ class ResultViewModel2 : ViewModel() { link, "$fileName ${link.name}", folder, - if (link.url.contains(".srt")) ".srt" else "vtt", + if (link.url.contains(".srt")) "srt" else "vtt", false, - null - ) { - // no notification - } + null, createNotificationCallback = {} + ) } } @@ -595,6 +665,9 @@ class ResultViewModel2 : ViewModel() { TvType.Live -> "LiveStreams" TvType.NSFW -> "NSFW" TvType.Others -> "Others" + TvType.Music -> "Music" + TvType.AudioBook -> "AudioBooks" + TvType.CustomMedia -> "Media" } } @@ -650,13 +723,13 @@ class ResultViewModel2 : ViewModel() { DOWNLOAD_HEADER_CACHE, parentId.toString(), VideoDownloadHelper.DownloadHeaderCached( - apiName, - url, - currentType, - currentHeaderName, - currentPoster, - parentId, - System.currentTimeMillis(), + apiName = apiName, + url = url, + type = currentType, + name = currentHeaderName, + poster = currentPoster, + id = parentId, + cacheTime = System.currentTimeMillis(), ) ) @@ -667,15 +740,15 @@ class ResultViewModel2 : ViewModel() { ), // 3 deep folder for faster acess episode.id.toString(), VideoDownloadHelper.DownloadEpisodeCached( - episode.name, - episode.poster, - episode.episode, - episode.season, - episode.id, - parentId, - episode.rating, - episode.description, - System.currentTimeMillis(), + name = episode.name, + poster = episode.poster, + episode = episode.episode, + season = episode.season, + id = episode.id, + parentId = parentId, + rating = episode.rating, + description = episode.description, + cacheTime = System.currentTimeMillis(), ) ) @@ -701,7 +774,7 @@ class ResultViewModel2 : ViewModel() { ) ) } - .map { ExtractorSubtitleLink(it.name, it.url, "") } + .map { ExtractorSubtitleLink(it.name, it.url, "") }.take(3) .forEach { link -> val fileName = VideoDownloadManager.getFileName(context, meta) downloadSubtitle(context, link, fileName, folder) @@ -727,7 +800,7 @@ class ResultViewModel2 : ViewModel() { val generator = RepoLinkGenerator(listOf(episode)) val currentLinks = mutableSetOf() val currentSubs = mutableSetOf() - generator.generateLinks(clearCache = false, isCasting = false, callback = { + generator.generateLinks(clearCache = false, LoadType.Chromecast, callback = { it.first?.let { link -> currentLinks.add(link) } @@ -738,7 +811,6 @@ class ResultViewModel2 : ViewModel() { if (currentLinks.isEmpty()) { main { showToast( - activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT ) @@ -747,7 +819,6 @@ class ResultViewModel2 : ViewModel() { } else { main { showToast( - activity, R.string.download_started, Toast.LENGTH_SHORT ) @@ -794,13 +865,84 @@ class ResultViewModel2 : ViewModel() { private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) val watchStatus: LiveData get() = _watchStatus - private val _selectPopup: MutableLiveData> = MutableLiveData(Some.None) - val selectPopup: LiveData> get() = _selectPopup + private val _selectPopup: MutableLiveData = MutableLiveData(null) + val selectPopup: LiveData = _selectPopup + fun updateWatchStatus( + status: WatchType, + context: Context?, + loadResponse: LoadResponse? = null, + statusChangedCallback: ((statusChanged: Boolean) -> Unit)? = null + ) { + val (response, currentId) = loadResponse?.let { load -> + (load to load.getId()) + } ?: ((currentResponse ?: return) to (currentId ?: return)) - fun updateWatchStatus(status: WatchType) { - updateWatchStatus(currentResponse ?: return, status) - _watchStatus.postValue(status) + val currentStatus = getResultWatchState(currentId) + + // If the current status is "NONE" and the new status is not "NONE", + // fetch the bookmarked data to check for duplicates, otherwise set this + // to an empty list, so that we don't show the duplicate warning dialog, + // but we still want to update the current bookmark and refresh the data anyway. + val bookmarkedData = if (currentStatus == WatchType.NONE && status != WatchType.NONE) { + getAllBookmarkedData() + } else emptyList() + + checkAndWarnDuplicates( + context, + LibraryListType.BOOKMARKS, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + bookmarkedData + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) return@checkAndWarnDuplicates + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + deleteBookmarkedData(duplicateId) + } + } + + setResultWatchState(currentId, status.internalId) + + // We don't need to store if WatchType.NONE. + // The key is removed in setResultWatchState, we don't want to + // re-add it again here if it was just removed. + if (status != WatchType.NONE) { + val current = getBookmarkedData(currentId) + + setBookmarkedData( + currentId, + DataStoreHelper.BookmarkedData( + current?.bookmarkedTime ?: unixTimeMS, + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + tags = response.tags, + rating = response.rating + ) + ) + } + + if (currentStatus != status) { + MainActivity.bookmarksUpdatedEvent(true) + MainActivity.reloadLibraryEvent(true) + } + + _watchStatus.postValue(status) + + statusChangedCallback?.invoke(true) + } } private fun startChromecast( @@ -809,11 +951,282 @@ class ResultViewModel2 : ViewModel() { isVisible: Boolean = true ) { if (activity == null) return - loadLinks(result, isVisible = isVisible, isCasting = true) { data -> + loadLinks(result, isVisible = isVisible, LoadType.Chromecast) { data -> startChromecast(activity, result, data.links, data.subs, 0) } } + /** + * Toggles the subscription status of an item. + * + * @param context The context to use for operations. + * @param statusChangedCallback A callback that is invoked when the subscription status changes. + * It provides the new subscription status (true if subscribed, false if unsubscribed, null if action was canceled). + */ + fun toggleSubscriptionStatus( + context: Context?, + statusChangedCallback: ((newStatus: Boolean?) -> Unit)? = null + ) { + val isSubscribed = _subscribeStatus.value ?: return + val response = currentResponse ?: return + val currentId = currentId ?: return + + // This might be a bit confusing, but even if the loadresponse is not a EpisodeResponse + // _subscribeStatus might be true. + + if (isSubscribed) { + removeSubscribedData(currentId) + statusChangedCallback?.invoke(false) + _subscribeStatus.postValue(if (response is EpisodeResponse) false else null) + MainActivity.reloadLibraryEvent(true) + } else { + if (response !is EpisodeResponse) { + return + } + checkAndWarnDuplicates( + context, + LibraryListType.SUBSCRIPTIONS, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + getAllSubscriptions(), + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) { + statusChangedCallback?.invoke(null) + return@checkAndWarnDuplicates + } + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + removeSubscribedData(duplicateId) + } + } + + val current = getSubscribedData(currentId) + + setSubscribedData( + currentId, + DataStoreHelper.SubscribedData( + current?.subscribedTime ?: unixTimeMS, + response.getLatestEpisodes(), + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + rating = response.rating, + tags = response.tags + ) + ) + + _subscribeStatus.postValue(true) + statusChangedCallback?.invoke(true) + MainActivity.reloadLibraryEvent(true) + } + } + } + + /** + * Toggles the favorite status of an item. + * + * @param context The context to use. + * @param statusChangedCallback A callback that is invoked when the favorite status changes. + * It provides the new favorite status (true if added to favorites, false if removed, null if action was canceled). + */ + fun toggleFavoriteStatus( + context: Context?, + statusChangedCallback: ((newStatus: Boolean?) -> Unit)? = null + ) { + val isFavorite = _favoriteStatus.value ?: return + val response = currentResponse ?: return + + val currentId = currentId ?: return + + if (isFavorite) { + removeFavoritesData(currentId) + statusChangedCallback?.invoke(false) + _favoriteStatus.postValue(false) + MainActivity.reloadLibraryEvent(true) + } else { + checkAndWarnDuplicates( + context, + LibraryListType.FAVORITES, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + getAllFavorites(), + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) { + statusChangedCallback?.invoke(null) + return@checkAndWarnDuplicates + } + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + removeFavoritesData(duplicateId) + } + } + + val current = getFavoritesData(currentId) + + setFavoritesData( + currentId, + DataStoreHelper.FavoritesData( + current?.favoritesTime ?: unixTimeMS, + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + rating = response.rating, + tags = response.tags + ) + ) + + _favoriteStatus.postValue(true) + statusChangedCallback?.invoke(true) + MainActivity.reloadLibraryEvent(true) + } + } + } + + @MainThread + private fun checkAndWarnDuplicates( + context: Context?, + listType: LibraryListType, + checkDuplicateData: CheckDuplicateData, + data: List, + checkDuplicatesCallback: (shouldContinue: Boolean, duplicateIds: List) -> Unit + ) { + val whitespaceRegex = "\\s+".toRegex() + fun normalizeString(input: String): String { + /** + * Trim the input string and replace consecutive spaces with a single space. + * This covers some edge-cases where the title does not match exactly across providers, + * and one provider has the title with an extra whitespace. This is minor enough that + * it should still match in this case. + */ + return input.trim().replace(whitespaceRegex, " ") + } + + val syncData = checkDuplicateData.syncData + + val imdbId = getImdbIdFromSyncData(syncData) + val tmdbId = getTMDbIdFromSyncData(syncData) + val malId = syncData?.get(AccountManager.malApi.idPrefix) + val aniListId = syncData?.get(AccountManager.aniListApi.idPrefix) + val normalizedName = normalizeString(checkDuplicateData.name) + val year = checkDuplicateData.year + + val duplicateEntries = data.filter { it: DataStoreHelper.LibrarySearchResponse -> + val librarySyncData = it.syncData + val yearCheck = year == it.year || year == null || it.year == null + + val checks = listOf( + { imdbId != null && getImdbIdFromSyncData(librarySyncData) == imdbId }, + { tmdbId != null && getTMDbIdFromSyncData(librarySyncData) == tmdbId }, + { malId != null && librarySyncData?.get(AccountManager.malApi.idPrefix) == malId }, + { aniListId != null && librarySyncData?.get(AccountManager.aniListApi.idPrefix) == aniListId }, + { normalizedName == normalizeString(it.name) && yearCheck } + ) + + checks.any { it() } + } + + if (duplicateEntries.isEmpty() || context == null) { + checkDuplicatesCallback.invoke(true, emptyList()) + return + } + + val replaceMessage = if (duplicateEntries.size > 1) { + R.string.duplicate_replace_all + } else R.string.duplicate_replace + + val message = if (duplicateEntries.size == 1) { + val list = when (listType) { + LibraryListType.BOOKMARKS -> getResultWatchState( + duplicateEntries[0].id ?: 0 + ).stringRes + + LibraryListType.FAVORITES -> R.string.favorites_list_name + LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name + } + + context.getString( + R.string.duplicate_message_single, + "${normalizeString(duplicateEntries[0].name)} (${context.getString(list)}) — ${duplicateEntries[0].apiName}" + ) + } else { + val bulletPoints = duplicateEntries.joinToString("\n") { + val list = when (listType) { + LibraryListType.BOOKMARKS -> getResultWatchState(it.id ?: 0).stringRes + LibraryListType.FAVORITES -> R.string.favorites_list_name + LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name + } + + "• ${it.apiName}: ${normalizeString(it.name)} (${context.getString(list)})" + } + + context.getString(R.string.duplicate_message_multiple, bulletPoints) + } + + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + checkDuplicatesCallback.invoke(true, emptyList()) + } + + DialogInterface.BUTTON_NEGATIVE -> { + checkDuplicatesCallback.invoke(false, emptyList()) + } + + DialogInterface.BUTTON_NEUTRAL -> { + checkDuplicatesCallback.invoke(true, duplicateEntries.map { it.id }) + } + } + } + + builder.setTitle(R.string.duplicate_title) + .setMessage(message) + .setPositiveButton(R.string.duplicate_add, dialogClickListener) + .setNegativeButton(R.string.duplicate_cancel, dialogClickListener) + .setNeutralButton(replaceMessage, dialogClickListener) + .show().setDefaultFocus() + } + + private fun getImdbIdFromSyncData(syncData: Map?): String? { + return normalSafeApiCall { + readIdFromString( + syncData?.get(AccountManager.simklApi.idPrefix) + )[SimklSyncServices.Imdb] + } + } + + private fun getTMDbIdFromSyncData(syncData: Map?): String? { + return normalSafeApiCall { + readIdFromString( + syncData?.get(AccountManager.simklApi.idPrefix) + )[SimklSyncServices.Tmdb] + } + } + private fun startChromecast( activity: Activity?, result: ResultEpisode, @@ -843,23 +1256,22 @@ class ResultViewModel2 : ViewModel() { } fun cancelLinks() { - println("called::cancelLinks") currentLoadLinkJob?.cancel() currentLoadLinkJob = null - _loadedLinks.postValue(Some.None) + _loadedLinks.postValue(null) } private fun postPopup(text: UiText, options: List, callback: suspend (Int?) -> Unit) { _selectPopup.postValue( - some(SelectPopup.SelectText( + SelectPopup.SelectText( text, options ) { value -> viewModelScope.launchSafe { - _selectPopup.postValue(Some.None) + _selectPopup.postValue(null) callback.invoke(value) } - }) + } ) } @@ -870,22 +1282,22 @@ class ResultViewModel2 : ViewModel() { callback: suspend (Int?) -> Unit ) { _selectPopup.postValue( - some(SelectPopup.SelectArray( + SelectPopup.SelectArray( text, options, ) { value -> viewModelScope.launchSafe { - _selectPopup.value = Some.None + _selectPopup.postValue(null) callback.invoke(value) } - }) + } ) } private fun loadLinks( result: ResultEpisode, isVisible: Boolean, - isCasting: Boolean, + type: LoadType, clearCache: Boolean = false, work: suspend (CoroutineScope.(LinkLoadingResult) -> Unit) ) { @@ -894,7 +1306,7 @@ class ResultViewModel2 : ViewModel() { val links = loadLinks( result, isVisible = isVisible, - isCasting = isCasting, + type = type, clearCache = clearCache ) if (!this.isActive) return@ioSafe @@ -905,14 +1317,20 @@ class ResultViewModel2 : ViewModel() { private var currentLoadLinkJob: Job? = null private fun acquireSingleLink( result: ResultEpisode, - isCasting: Boolean, + type: LoadType, text: UiText, callback: (Pair) -> Unit, ) { - loadLinks(result, isVisible = true, isCasting = isCasting) { links -> + loadLinks(result, isVisible = true, type) { links -> + // Could not find a better way to do this + val context = AcraApplication.context postPopup( text, - links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") }) { + links.links.apmap { + val size = + it.getVideoSize()?.let { size -> " " + formatFileSize(context, size) } ?: "" + txt("${it.name} ${Qualities.getStringByInt(it.quality)}$size") + }) { callback.invoke(links to (it ?: return@postPopup)) } } @@ -920,11 +1338,10 @@ class ResultViewModel2 : ViewModel() { private fun acquireSingleSubtitle( result: ResultEpisode, - isCasting: Boolean, text: UiText, callback: (Pair) -> Unit, ) { - loadLinks(result, isVisible = true, isCasting = isCasting) { links -> + loadLinks(result, isVisible = true, type = LoadType.Unknown) { links -> postPopup( text, links.subs.map { txt(it.name) }) @@ -937,7 +1354,7 @@ class ResultViewModel2 : ViewModel() { private suspend fun CoroutineScope.loadLinks( result: ResultEpisode, isVisible: Boolean, - isCasting: Boolean, + type: LoadType, clearCache: Boolean = false, ): LinkLoadingResult { val tempGenerator = RepoLinkGenerator(listOf(result)) @@ -946,12 +1363,12 @@ class ResultViewModel2 : ViewModel() { val subs: MutableSet = mutableSetOf() fun updatePage() { if (isVisible && isActive) { - _loadedLinks.postValue(some(LinkProgress(links.size, subs.size))) + _loadedLinks.postValue(LinkProgress(links.size, subs.size)) } } try { updatePage() - tempGenerator.generateLinks(clearCache, isCasting, { (link, _) -> + tempGenerator.generateLinks(clearCache, type, { (link, _) -> if (link != null) { links += link updatePage() @@ -963,7 +1380,7 @@ class ResultViewModel2 : ViewModel() { } catch (e: Exception) { logError(e) } finally { - _loadedLinks.postValue(Some.None) + _loadedLinks.postValue(null) } return LinkLoadingResult(sortUrls(links), sortSubs(subs)) @@ -971,7 +1388,7 @@ class ResultViewModel2 : ViewModel() { private fun launchActivity( activity: Activity?, - resumeApp: ResultResume, + resumeApp: MainActivity.Companion.ResultResume, id: Int? = null, work: suspend (Intent.(Activity) -> Unit) ): Job? { @@ -985,9 +1402,9 @@ class ResultViewModel2 : ViewModel() { logError(t) main { if (t is ActivityNotFoundException) { - showToast(activity, txt(R.string.app_not_found_error), Toast.LENGTH_LONG) + showToast(txt(R.string.app_not_found_error), Toast.LENGTH_LONG) } else { - showToast(activity, t.toString(), Toast.LENGTH_LONG) + showToast(t.toString(), Toast.LENGTH_LONG) } } } @@ -1084,16 +1501,21 @@ class ResultViewModel2 : ViewModel() { 1L } - component = VLC_COMPONENT + // Component no longer safe to use in A13 for VLC + // https://code.videolan.org/videolan/vlc-android/-/issues/2776 + // This will likely need to be updated once VLC fixes their documentation. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + component = VLC_COMPONENT + } putExtra("from_start", !resume) putExtra("position", position) } - fun handleAction(activity: Activity?, click: EpisodeClickEvent) = + fun handleAction(click: EpisodeClickEvent) = viewModelScope.launchSafe { - handleEpisodeClickEvent(activity, click) + handleEpisodeClickEvent(click) } data class ExternalApp( @@ -1123,7 +1545,7 @@ class ResultViewModel2 : ViewModel() { _episodeSynopsis.postValue(null) } - private suspend fun handleEpisodeClickEvent(activity: Activity?, click: EpisodeClickEvent) { + private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { val options = mutableListOf>() @@ -1135,6 +1557,13 @@ class ResultViewModel2 : ViewModel() { ) ) } + + if (FcastManager.currentDevices.isNotEmpty()) { + options.add( + txt(R.string.player_settings_play_in_fcast) to ACTION_FCAST + ) + } + options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) for (app in apps) { @@ -1162,7 +1591,7 @@ class ResultViewModel2 : ViewModel() { // Do not add mark as watched on movies if (!listOf(TvType.Movie, TvType.AnimeMovie).contains(click.data.tvType)) { val isWatched = - DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched + getVideoWatchState(click.data.id) == VideoWatchState.Watched val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched @@ -1181,27 +1610,26 @@ class ResultViewModel2 : ViewModel() { options ) { result -> handleEpisodeClickEvent( - activity, click.copy(action = result ?: return@postPopup) ) } } + ACTION_CLICK_DEFAULT -> { activity?.let { ctx -> if (ctx.isConnectedToChromecast()) { handleEpisodeClickEvent( - activity, click.copy(action = ACTION_CHROME_CAST_EPISODE) ) } else { val action = getPlayerAction(ctx) handleEpisodeClickEvent( - activity, click.copy(action = action) ) } } } + ACTION_SHOW_DESCRIPTION -> { _episodeSynopsis.postValue(click.data.description) } @@ -1217,7 +1645,6 @@ class ResultViewModel2 : ViewModel() { acquireSingleSubtitle( click.data, - false, txt(R.string.episode_action_download_subtitle) ) { (links, index) -> downloadSubtitle( @@ -1233,15 +1660,16 @@ class ResultViewModel2 : ViewModel() { ) ) showToast( - activity, R.string.download_started, Toast.LENGTH_SHORT ) } } + ACTION_SHOW_TOAST -> { - showToast(activity, R.string.play_episode_toast, Toast.LENGTH_SHORT) + showToast(R.string.play_episode_toast, Toast.LENGTH_SHORT) } + ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return downloadEpisode( @@ -1256,11 +1684,12 @@ class ResultViewModel2 : ViewModel() { response.url ) } + ACTION_DOWNLOAD_MIRROR -> { val response = currentResponse ?: return acquireSingleLink( click.data, - false, + LoadType.InAppDownload, txt(R.string.episode_action_download_mirror) ) { (result, index) -> ioSafe { @@ -1279,34 +1708,73 @@ class ResultViewModel2 : ViewModel() { ) } showToast( - activity, R.string.download_started, Toast.LENGTH_SHORT ) } } + ACTION_RELOAD_EPISODE -> { ioSafe { loadLinks( click.data, isVisible = false, - isCasting = false, + type = LoadType.InApp, clearCache = true ) } + showToast( + R.string.links_reloaded_toast, + Toast.LENGTH_SHORT + ) } + ACTION_CHROME_CAST_MIRROR -> { acquireSingleLink( click.data, - isCasting = true, + LoadType.Chromecast, txt(R.string.episode_action_chromecast_mirror) ) { (result, index) -> startChromecast(activity, click.data, result.links, result.subs, index) } } + + ACTION_FCAST -> { + val devices = FcastManager.currentDevices.toList() + postPopup( + txt(R.string.player_settings_select_cast_device), + devices.map { txt(it.name) }) { index -> + if (index == null) return@postPopup + val device = devices.getOrNull(index) + + acquireSingleLink( + click.data, + LoadType.Fcast, + txt(R.string.episode_action_cast_mirror) + ) { (result, index) -> + val host = device?.host ?: return@acquireSingleLink + val link = result.links.getOrNull(index) ?: return@acquireSingleLink + + FcastSession(host).use { session -> + session.sendMessage( + Opcode.Play, + PlayMessage( + link.type.getMimeType(), + link.url, + headers = mapOf( + "referer" to link.referer, + "user-agent" to USER_AGENT + ) + link.headers + ) + ) + } + } + } + } + ACTION_PLAY_EPISODE_IN_BROWSER -> acquireSingleLink( click.data, - isCasting = true, + LoadType.Browser, txt(R.string.episode_action_play_in_browser) ) { (result, index) -> try { @@ -1317,29 +1785,26 @@ class ResultViewModel2 : ViewModel() { logError(e) } } + ACTION_COPY_LINK -> { acquireSingleLink( click.data, - isCasting = true, + LoadType.ExternalApp, txt(R.string.episode_action_copy_link) ) { (result, index) -> - val act = activity ?: return@acquireSingleLink - val serviceClipboard = - (act.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?) - ?: return@acquireSingleLink val link = result.links[index] - val clip = ClipData.newPlainText(link.name, link.url) - serviceClipboard.setPrimaryClip(clip) - showToast(act, R.string.copy_link_toast, Toast.LENGTH_SHORT) + clipboardHelper(txt(link.name), link.url) } } + ACTION_CHROME_CAST_EPISODE -> { startChromecast(activity, click.data) } + ACTION_PLAY_EPISODE_IN_VLC_PLAYER -> { - loadLinks(click.data, isVisible = true, isCasting = true) { links -> + loadLinks(click.data, isVisible = true, LoadType.ExternalApp) { links -> if (links.links.isEmpty()) { - showToast(activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT) + showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) return@loadLinks } @@ -1350,9 +1815,10 @@ class ResultViewModel2 : ViewModel() { ) } } + ACTION_PLAY_EPISODE_IN_WEB_VIDEO -> acquireSingleLink( click.data, - isCasting = true, + LoadType.Chromecast, txt( R.string.episode_action_play_in_format, txt(R.string.player_settings_play_in_web) @@ -1366,9 +1832,10 @@ class ResultViewModel2 : ViewModel() { result.subs ) } + ACTION_PLAY_EPISODE_IN_MPV -> acquireSingleLink( click.data, - isCasting = true, + LoadType.Chromecast, txt( R.string.episode_action_play_in_format, txt(R.string.player_settings_play_in_mpv) @@ -1381,34 +1848,43 @@ class ResultViewModel2 : ViewModel() { result.subs ) } + ACTION_PLAY_EPISODE_IN_PLAYER -> { val data = currentResponse?.syncData?.toList() ?: emptyList() val list = HashMap().apply { putAll(data) } - - activity?.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - generator?.also { - it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work - ?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id } - ?.let { index -> - if (index >= 0) - it.goto(index) - } - - } ?: return, list + generator?.also { + it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work + ?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id } + ?.let { index -> + if (index >= 0) + it.goto(index) + } + } + if (currentResponse?.type == TvType.CustomMedia) { + generator?.generateLinks( + clearCache = true, + LoadType.Unknown, + callback = {}, + subtitleCallback = {}) + } else { + activity?.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + generator ?: return, list + ) ) - ) + } } + ACTION_MARK_AS_WATCHED -> { val isWatched = - DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched + getVideoWatchState(click.data.id) == VideoWatchState.Watched if (isWatched) { - DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.None) + setVideoWatchState(click.data.id, VideoWatchState.None) } else { - DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.Watched) + setVideoWatchState(click.data.id, VideoWatchState.Watched) } // Kinda dirty to reload all episodes :( @@ -1422,85 +1898,134 @@ class ResultViewModel2 : ViewModel() { meta: SyncAPI.SyncResult?, syncs: Map? = null ): Pair { - if (meta == null) return resp to false + //if (meta == null) return resp to false var updateEpisodes = false val out = resp.apply { Log.i(TAG, "applyMeta") - duration = duration ?: meta.duration - rating = rating ?: meta.publicScore - tags = tags ?: meta.genres - plot = if (plot.isNullOrBlank()) meta.synopsis else plot - posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl - actors = actors ?: meta.actors + if (meta != null) { + duration = duration ?: meta.duration + rating = rating ?: meta.publicScore + tags = tags ?: meta.genres + plot = if (plot.isNullOrBlank()) meta.synopsis else plot + posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl + actors = actors ?: meta.actors - if (this is EpisodeResponse) { - nextAiring = nextAiring ?: meta.nextAiring + if (this is EpisodeResponse) { + nextAiring = nextAiring ?: meta.nextAiring + } + + val realRecommendations = ArrayList() + val apiNames = synchronized(apis) { + apis.filter { + it.name.contains("gogoanime", true) || + it.name.contains("9anime", true) + }.map { + it.name + } + } + meta.recommendations?.forEach { rec -> + apiNames.forEach { name -> + realRecommendations.add(rec.copy(apiName = name)) + } + } + + recommendations = recommendations?.union(realRecommendations)?.toList() + ?: realRecommendations } for ((k, v) in syncs ?: emptyMap()) { syncData[k] = v } - val realRecommendations = ArrayList() - // TODO: fix - val apiNames = apis.filter { - it.name.contains("gogoanime", true) || - it.name.contains("9anime", true) - }.map { - it.name - } + argamap( + { + if (this !is AnimeLoadResponse) return@argamap + // already exist, no need to run getTracker + if (this.getAniListId() != null && this.getMalId() != null) return@argamap - meta.recommendations?.forEach { rec -> - apiNames.forEach { name -> - realRecommendations.add(rec.copy(apiName = name)) - } - } - - recommendations = recommendations?.union(realRecommendations)?.toList() - ?: realRecommendations - - argamap({ - addTrailer(meta.trailers) - }, { - if (this !is AnimeLoadResponse) return@argamap - val map = - Kitsu.getEpisodesDetails( - getMalId(), - getAniListId(), - isResponseRequired = false + val res = APIHolder.getTracker( + listOfNotNull( + this.engName, + this.name, + this.japName + ).filter { it.length > 2 } + .distinct().map { + // this actually would be nice if we improved a bit as 3rd season == season 3 == III ect + // right now it just removes the dubbed status + it.lowercase().replace(Regex("""\(?[ds]ub(bed)?\)?(\s|$)"""), "") + .trim() + }, + TrackerType.getTypes(this.type), + this.year ) - if (map.isNullOrEmpty()) return@argamap - updateEpisodes = DubStatus.values().map { dubStatus -> - val current = - this.episodes[dubStatus]?.mapIndexed { index, episode -> - episode.apply { - this.episode = this.episode ?: (index + 1) - } - }?.sortedBy { it.episode ?: 0 }?.toMutableList() - if (current.isNullOrEmpty()) return@map false - val episodeNumbers = current.map { ep -> ep.episode!! } - var updateCount = 0 - map.forEach { (episode, node) -> - episodeNumbers.binarySearch(episode).let { index -> - current.getOrNull(index)?.let { currentEp -> - current[index] = currentEp.apply { - updateCount++ - val currentBack = this - this.description = this.description ?: node.description?.en - this.name = this.name ?: node.titles?.canonical - this.episode = - this.episode ?: node.num ?: episodeNumbers[index] - this.posterUrl = - this.posterUrl ?: node.thumbnail?.original?.url + + val ids = arrayOf( + AccountManager.malApi.idPrefix to res?.malId?.toString(), + AccountManager.aniListApi.idPrefix to res?.aniId + ) + + if (ids.any { (id, new) -> + val current = syncData[id] + new != null && current != null && current != new + } + ) { + // getTracker fucked up as it conflicts with current implementation + return@argamap + } + + // set all the new data, prioritise old correct data + ids.forEach { (id, new) -> + new?.let { + syncData[id] = syncData[id] ?: it + } + } + + // set posters, might fuck up due to headers idk + posterUrl = posterUrl ?: res?.image + backgroundPosterUrl = backgroundPosterUrl ?: res?.cover + }, + { + if (meta == null) return@argamap + addTrailer(meta.trailers) + }, { + if (this !is AnimeLoadResponse) return@argamap + val map = + Kitsu.getEpisodesDetails( + getMalId(), + getAniListId(), + isResponseRequired = false + ) + if (map.isNullOrEmpty()) return@argamap + updateEpisodes = DubStatus.values().map { dubStatus -> + val current = + this.episodes[dubStatus]?.mapIndexed { index, episode -> + episode.apply { + this.episode = this.episode ?: (index + 1) + } + }?.sortedBy { it.episode ?: 0 }?.toMutableList() + if (current.isNullOrEmpty()) return@map false + val episodeNumbers = current.map { ep -> ep.episode!! } + var updateCount = 0 + map.forEach { (episode, node) -> + episodeNumbers.binarySearch(episode).let { index -> + current.getOrNull(index)?.let { currentEp -> + current[index] = currentEp.apply { + updateCount++ + this.description = this.description ?: node.description?.en + this.name = this.name ?: node.titles?.canonical + this.episode = + this.episode ?: node.num ?: episodeNumbers[index] + this.posterUrl = + this.posterUrl ?: node.thumbnail?.original?.url + } } } } - } - this.episodes[dubStatus] = current - updateCount > 0 - }.any { it } - }) + this.episodes[dubStatus] = current + updateCount > 0 + }.any { it } + }) } return out to updateEpisodes } @@ -1524,6 +2049,7 @@ class ResultViewModel2 : ViewModel() { postSuccessful( value ?: return@launchSafe, + currentId ?: return@launchSafe, currentRepo ?: return@launchSafe, updateEpisodes ?: return@launchSafe, false @@ -1569,7 +2095,7 @@ class ResultViewModel2 : ViewModel() { list.subList(start, end).map { val posDur = getViewPos(it.id) val watchState = - DataStoreHelper.getVideoWatchState(it.id) ?: VideoWatchState.None + getVideoWatchState(it.id) ?: VideoWatchState.None it.copy( position = posDur?.position ?: 0, duration = posDur?.duration ?: 0, @@ -1582,10 +2108,10 @@ class ResultViewModel2 : ViewModel() { private fun postMovie() { val response = currentResponse - _episodes.postValue(ResourceSome.None) + _episodes.postValue(null) if (response == null) { - _movie.postValue(ResourceSome.None) + _movie.postValue(null) return } @@ -1595,18 +2121,18 @@ class ResultViewModel2 : ViewModel() { else -> { if (response.type.isLiveStream()) R.string.play_livestream_button - else if (response.type.isMovieType()) // this wont break compatibility as you only need to override isMovieType + else if (response.isMovie()) // this wont break compatibility as you only need to override isMovieType R.string.play_movie_button else null } } ) val data = getMovie() - _episodes.postValue(ResourceSome.None) + _episodes.postValue(null) if (text == null || data == null) { - _movie.postValue(ResourceSome.None) + _movie.postValue(null) } else { - _movie.postValue(ResourceSome.Success(text to data)) + _movie.postValue(Resource.Success(text to data)) } } @@ -1615,18 +2141,37 @@ class ResultViewModel2 : ViewModel() { postMovie() } else { _episodes.postValue( - ResourceSome.Success( + Resource.Success( getEpisodes( currentIndex ?: return, currentRange ?: return ) ) ) - _movie.postValue(ResourceSome.None) + _movie.postValue(null) } postResume() } + private fun postSubscription(loadResponse: LoadResponse) { + val id = loadResponse.getId() + val data = getSubscribedData(id) + if (loadResponse.isEpisodeBased()) { + updateSubscribedData(id, data, loadResponse as? EpisodeResponse) + _subscribeStatus.postValue(data != null) + } + // lets say that we have subscribed, then we must be able to unsubscribe no matter what + else if (data != null) { + _subscribeStatus.postValue(true) + } else _subscribeStatus.postValue(null) + } + + private fun postFavorites(loadResponse: LoadResponse) { + val id = loadResponse.getId() + val isFavorite = getFavoritesData(id) != null + _favoriteStatus.postValue(isFavorite) + } + private fun postEpisodeRange(indexer: EpisodeIndexer?, range: EpisodeRange?) { if (range == null || indexer == null) { return @@ -1655,14 +2200,14 @@ class ResultViewModel2 : ViewModel() { val size = currentEpisodes[indexer]?.size _episodesCountText.postValue( - some( - if (isMovie) null else - txt( - R.string.episode_format, - size, - txt(if (size == 1) R.string.episode else R.string.episodes), - ) - ) + + if (isMovie) null else + txt( + R.string.episode_format, + size, + txt(if (size == 1) R.string.episode else R.string.episodes), + ) + ) _selectedSeasonIndex.postValue( @@ -1670,29 +2215,29 @@ class ResultViewModel2 : ViewModel() { ) _selectedSeason.postValue( - some( - if (isMovie || currentSeasons.size <= 1) null else - when (indexer.season) { - 0 -> txt(R.string.no_season) - else -> { - val seasonNames = (currentResponse as? EpisodeResponse)?.seasonNames - val seasonData = seasonNames.getSeason(indexer.season) - // If displaySeason is null then only show the name! - if (seasonData?.name != null && seasonData.displaySeason == null) { - txt(seasonData.name) - } else { - val suffix = seasonData?.name?.let { " $it" } ?: "" - txt( - R.string.season_format, - txt(R.string.season), - seasonData?.displaySeason ?: indexer.season, - suffix - ) - } + if (isMovie || currentSeasons.size <= 1) null else + when (indexer.season) { + 0 -> txt(R.string.no_season) + else -> { + val seasonNames = (currentResponse as? EpisodeResponse)?.seasonNames + val seasonData = seasonNames.getSeason(indexer.season) + + // If displaySeason is null then only show the name! + if (seasonData?.name != null && seasonData.displaySeason == null) { + txt(seasonData.name) + } else { + val suffix = seasonData?.name?.let { " $it" } ?: "" + txt( + R.string.season_format, + txt(R.string.season), + seasonData?.displaySeason ?: indexer.season, + suffix + ) } } - ) + } + ) _selectedRangeIndex.postValue( @@ -1700,13 +2245,13 @@ class ResultViewModel2 : ViewModel() { ) _selectedRange.postValue( - some( - if (isMovie) null else if ((currentRanges[indexer]?.size ?: 0) > 1) { - txt(R.string.episodes_range, range.startEpisode, range.endEpisode) - } else { - null - } - ) + + if (isMovie) null else if ((currentRanges[indexer]?.size ?: 0) > 1) { + txt(R.string.episodes_range, range.startEpisode, range.endEpisode) + } else { + null + } + ) _selectedDubStatusIndex.postValue( @@ -1714,10 +2259,10 @@ class ResultViewModel2 : ViewModel() { ) _selectedDubStatus.postValue( - some( - if (isMovie || currentDubStatus.size <= 1) null else - txt(indexer.dubStatus) - ) + + if (isMovie || currentDubStatus.size <= 1) null else + txt(indexer.dubStatus) + ) currentId?.let { id -> @@ -1751,29 +2296,34 @@ class ResultViewModel2 : ViewModel() { } }*/ - _episodes.postValue(ResourceSome.Success(ret)) + _episodes.postValue(Resource.Success(ret)) } } private suspend fun postSuccessful( loadResponse: LoadResponse, + mainId: Int, apiRepository: APIRepository, updateEpisodes: Boolean, updateFillers: Boolean, ) { + currentId = mainId currentResponse = loadResponse postPage(loadResponse, apiRepository) + postSubscription(loadResponse) + postFavorites(loadResponse) + _watchStatus.postValue(getResultWatchState(mainId)) + if (updateEpisodes) - postEpisodes(loadResponse, updateFillers) + postEpisodes(loadResponse, mainId, updateFillers) } - private suspend fun postEpisodes(loadResponse: LoadResponse, updateFillers: Boolean) { - _episodes.postValue(ResourceSome.Loading()) - - val mainId = loadResponse.getId() - currentId = mainId - - _watchStatus.postValue(getResultWatchState(mainId)) + private suspend fun postEpisodes( + loadResponse: LoadResponse, + mainId: Int, + updateFillers: Boolean + ) { + _episodes.postValue(Resource.Loading()) if (updateFillers && loadResponse is AnimeLoadResponse) { updateFillers(loadResponse.name) @@ -1791,6 +2341,15 @@ class ResultViewModel2 : ViewModel() { val id = mainId + episode + idIndex * 1_000_000 + (i.season?.times(10_000) ?: 0) + + val totalIndex = + i.season?.let { season -> + loadResponse.getTotalEpisodeIndex( + episode, + season + ) + } + if (!existingEpisodes.contains(id)) { existingEpisodes.add(id) val seasonData = loadResponse.seasonNames.getSeason(i.season) @@ -1810,7 +2369,10 @@ class ResultViewModel2 : ViewModel() { i.description, fillers.getOrDefault(episode, false), loadResponse.type, - mainId + mainId, + totalIndex, + airDate = i.date, + runTime = i.runTime, ) val season = eps.seasonIndex ?: 0 @@ -1823,6 +2385,7 @@ class ResultViewModel2 : ViewModel() { } episodes } + is TvSeriesLoadResponse -> { val episodes: MutableMap> = mutableMapOf() @@ -1838,6 +2401,14 @@ class ResultViewModel2 : ViewModel() { val seasonData = loadResponse.seasonNames.getSeason(episode.season) + val totalIndex = + episode.season?.let { season -> + loadResponse.getTotalEpisodeIndex( + episodeIndex, + season + ) + } + val ep = buildResultEpisode( loadResponse.name, @@ -1854,7 +2425,10 @@ class ResultViewModel2 : ViewModel() { episode.description, null, loadResponse.type, - mainId + mainId, + totalIndex, + airDate = episode.date, + runTime = episode.runTime, ) val season = ep.seasonIndex ?: 0 @@ -1867,6 +2441,7 @@ class ResultViewModel2 : ViewModel() { } episodes } + is MovieLoadResponse -> { singleMap( buildResultEpisode( @@ -1884,10 +2459,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null, ) ) } + is LiveStreamLoadResponse -> { singleMap( buildResultEpisode( @@ -1905,10 +2482,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } + is TorrentLoadResponse -> { singleMap( buildResultEpisode( @@ -1926,10 +2505,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } + else -> { mapOf() } @@ -1965,7 +2546,7 @@ class ResultViewModel2 : ViewModel() { } currentEpisodes = allEpisodes - val ranges = getRanges(allEpisodes) + val ranges = getRanges(allEpisodes, EPISODE_RANGE_SIZE) currentRanges = ranges @@ -1986,13 +2567,13 @@ class ResultViewModel2 : ViewModel() { postResume() } - fun postResume() { - _resumeWatching.postValue(some(resume())) + private fun postResume() { + _resumeWatching.postValue(resume()) } private fun resume(): ResumeWatchingStatus? { val correctId = currentId ?: return null - val resume = DataStoreHelper.getLastWatched(correctId) + val resume = getLastWatched(correctId) val resumeParentId = resume?.parentId if (resumeParentId != correctId) return null // is null or smth went wrong with getLastWatched val resumeId = resume.episodeId ?: return null// invalid episode id @@ -2007,7 +2588,13 @@ class ResultViewModel2 : ViewModel() { ResumeProgress( progress = (viewPos.position / 1000).toInt(), maxProgress = (viewPos.duration / 1000).toInt(), - txt(R.string.resume_time_left, (viewPos.duration - viewPos.position) / (60_000)) + txt( + R.string.resume_remaining, + secondsToReadable( + ((viewPos.duration - viewPos.position) / 1_000).toInt(), + "0 mins" + ) + ) ) } @@ -2047,7 +2634,7 @@ class ResultViewModel2 : ViewModel() { trailerData.extractorUrl, trailerData.referer ?: "", Qualities.Unknown.value, - trailerData.extractorUrl.contains(".m3u8") + type = INFER_TYPE ) ) to arrayListOf() } else { @@ -2085,7 +2672,6 @@ class ResultViewModel2 : ViewModel() { for (ep in currentRange) { if (ep.getWatchProgress() > 0.9) continue handleAction( - activity, EpisodeClickEvent( getPlayerAction(activity), ep @@ -2095,6 +2681,7 @@ class ResultViewModel2 : ViewModel() { } } } + START_ACTION_LOAD_EP -> { val all = currentEpisodes.values.flatten() val episode = @@ -2105,7 +2692,6 @@ class ResultViewModel2 : ViewModel() { } ?: return@launchSafe handleAction( - activity, EpisodeClickEvent( getPlayerAction(activity), episode @@ -2115,6 +2701,67 @@ class ResultViewModel2 : ViewModel() { } } + data class LoadResponseFromSearch( + override var name: String, + override var url: String, + override var apiName: String, + override var type: TvType, + override var posterUrl: String?, + override var year: Int? = null, + override var plot: String? = null, + override var rating: Int? = null, + override var tags: List? = null, + override var duration: Int? = null, + override var trailers: MutableList = mutableListOf(), + override var recommendations: List? = null, + override var actors: List? = null, + override var comingSoon: Boolean = false, + override var syncData: MutableMap = mutableMapOf(), + override var posterHeaders: Map? = null, + override var backgroundPosterUrl: String? = null, + override var contentRating: String? = null, + val id: Int?, + ) : LoadResponse + + fun loadSmall(searchResponse: SearchResponse) = ioSafe { + val url = searchResponse.url + _page.postValue(Resource.Loading(url)) + _episodes.postValue(Resource.Loading()) + val api = + APIHolder.getApiFromNameNull(searchResponse.apiName) ?: APIHolder.getApiFromUrlNull( + searchResponse.url + ) ?: APIRepository.noneApi + val repo = APIRepository(api) + val response = LoadResponseFromSearch( + name = searchResponse.name, + url = searchResponse.url, + apiName = api.name, + type = searchResponse.type ?: TvType.Others, + posterUrl = searchResponse.posterUrl, + id = searchResponse.id + ).apply { + if (searchResponse is SyncAPI.LibraryItem) { + this.plot = searchResponse.plot + this.rating = searchResponse.personalRating?.times(100) ?: searchResponse.rating + this.tags = searchResponse.tags + } + if (searchResponse is DataStoreHelper.BookmarkedData) { + this.plot = searchResponse.plot + this.rating = searchResponse.rating + this.tags = searchResponse.tags + } + } + val mainId = response.getId() + + postSuccessful( + loadResponse = response, + mainId = mainId, + apiRepository = repo, + updateEpisodes = false, + updateFillers = false + ) + } + fun load( activity: Activity?, url: String, @@ -2124,9 +2771,9 @@ class ResultViewModel2 : ViewModel() { autostart: AutoResume?, loadTrailers: Boolean = true, ) = - viewModelScope.launchSafe { + ioSafe { _page.postValue(Resource.Loading(url)) - _episodes.postValue(ResourceSome.Loading()) + _episodes.postValue(Resource.Loading()) preferDubStatus = dubStatus currentShowFillers = showFillers @@ -2142,7 +2789,7 @@ class ResultViewModel2 : ViewModel() { "This provider does not exist" ) ) - return@launchSafe + return@ioSafe } @@ -2153,21 +2800,15 @@ class ResultViewModel2 : ViewModel() { api ) } - // TODO: fix - // val validUrlResource = safeApiCall { - // SyncRedirector.redirect( - // url, - // api.mainUrl.replace(NineAnimeProvider().mainUrl, "9anime") - // .replace(GogoanimeProvider().mainUrl, "gogoanime") - // ) - // } + if (validUrlResource !is Resource.Success) { if (validUrlResource is Resource.Failure) { _page.postValue(validUrlResource) } - return@launchSafe + return@ioSafe } + val validUrl = validUrlResource.value val repo = APIRepository(api) currentRepo = repo @@ -2176,12 +2817,13 @@ class ResultViewModel2 : ViewModel() { is Resource.Failure -> { _page.postValue(data) } + is Resource.Success -> { - if (!isActive) return@launchSafe + if (!isActive) return@ioSafe val loadResponse = ioWork { applyMeta(data.value, currentMeta, currentSync).first } - if (!isActive) return@launchSafe + if (!isActive) return@ioSafe val mainId = loadResponse.getId() preferDubStatus = getDub(mainId) ?: preferDubStatus @@ -2192,26 +2834,28 @@ class ResultViewModel2 : ViewModel() { DOWNLOAD_HEADER_CACHE, mainId.toString(), VideoDownloadHelper.DownloadHeaderCached( - apiName, - validUrl, - loadResponse.type, - loadResponse.name, - loadResponse.posterUrl, - mainId, - System.currentTimeMillis(), + apiName = apiName, + url = validUrl, + type = loadResponse.type, + name = loadResponse.name, + poster = loadResponse.posterUrl, + id = mainId, + cacheTime = System.currentTimeMillis(), ) ) if (loadTrailers) loadTrailers(data.value) postSuccessful( data.value, + mainId, updateEpisodes = true, updateFillers = showFillers, apiRepository = repo ) - if (!isActive) return@launchSafe + if (!isActive) return@ioSafe handleAutoStart(activity, autostart) } + is Resource.Loading -> { debugException { "Invalid load result" } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt index 2e7ec529..8752e275 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt @@ -1,13 +1,13 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.databinding.ResultSelectionBinding +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout typealias SelectData = Pair @@ -17,7 +17,9 @@ class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter Unit) : RecyclerView.Adapter) { @@ -71,22 +63,16 @@ class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter Unit ) { - val isTrueTv = isTrueTvSettings() - if (isTrueTv) { + if (isLayout(TV)) { item.isFocusable = true item.isFocusableInTouchMode = true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 91415d26..51d3f50c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt @@ -10,7 +10,9 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.SyncUtil import java.util.* @@ -31,23 +33,23 @@ class SyncViewModel : ViewModel() { private val repos = SyncApis - private val _metaResponse: MutableLiveData> = - MutableLiveData() - - val metadata: LiveData> get() = _metaResponse - - private val _userDataResponse: MutableLiveData?> = + private val _metaResponse: MutableLiveData?> = MutableLiveData(null) - val userData: LiveData?> get() = _userDataResponse + val metadata: LiveData?> = _metaResponse + + private val _userDataResponse: MutableLiveData?> = + MutableLiveData(null) + + val userData: LiveData?> = _userDataResponse // prefix, id - private var syncs = mutableMapOf() + private val syncs = mutableMapOf() //private val _syncIds: MutableLiveData> = // MutableLiveData(mutableMapOf()) //val syncIds: LiveData> get() = _syncIds - fun getSyncs() : Map { + fun getSyncs(): Map { return syncs } @@ -55,7 +57,7 @@ class SyncViewModel : ViewModel() { MutableLiveData(getMissing()) // pair of name idPrefix isSynced - val synced: LiveData> get() = _currentSynced + val synced: LiveData> = _currentSynced private fun getMissing(): List { return repos.map { @@ -106,7 +108,7 @@ class SyncViewModel : ViewModel() { Log.i(TAG, "addFromUrl = $url") if (url == null || hasAddedFromUrl.contains(url)) return@ioSafe - if(!url.startsWith("http")) return@ioSafe + if (!url.startsWith("http")) return@ioSafe SyncUtil.getIdsFromUrl(url)?.let { (malId, aniListId) -> hasAddedFromUrl.add(url) @@ -150,7 +152,8 @@ class SyncViewModel : ViewModel() { val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(watchedEpisodes = episodes))) + user.value.watchedEpisodes = episodes + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -158,7 +161,8 @@ class SyncViewModel : ViewModel() { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(score = score))) + user.value.score = score + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -167,7 +171,8 @@ class SyncViewModel : ViewModel() { if (which < -1 || which > 5) return // validate input val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(status = which))) + user.value.status = SyncWatchType.fromInternalId(which) + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -185,17 +190,16 @@ class SyncViewModel : ViewModel() { fun modifyMaxEpisode(episodeNum: Int) { Log.i(TAG, "modifyMaxEpisode = $episodeNum") modifyData { status -> - status.copy( - watchedEpisodes = maxOf( - episodeNum, - status.watchedEpisodes ?: return@modifyData null - ) + status.watchedEpisodes = maxOf( + episodeNum, + status.watchedEpisodes ?: return@modifyData null ) + status } } /// modifies the current sync data, return null if you don't want to change it - private fun modifyData(update: ((SyncAPI.SyncStatus) -> (SyncAPI.SyncStatus?))) = + private fun modifyData(update: ((SyncAPI.AbstractSyncStatus) -> (SyncAPI.AbstractSyncStatus?))) = ioSafe { syncs.amap { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> @@ -245,8 +249,12 @@ class SyncViewModel : ViewModel() { // shitty way to sort anilist first, as it has trailers while mal does not if (syncs.containsKey(aniListApi.idPrefix)) { try { // swap can throw error - Collections.swap(current, current.indexOfFirst { it.first == aniListApi.idPrefix }, 0) - } catch (t : Throwable) { + Collections.swap( + current, + current.indexOfFirst { it.first == aniListApi.idPrefix }, + 0 + ) + } catch (t: Throwable) { logError(t) } } @@ -273,7 +281,33 @@ class SyncViewModel : ViewModel() { setEpisodesDelta(0) } + fun syncName(syncName: String) : String? { + // fix because of bad old data :pensive: + val realName = when(syncName) { + "MAL" -> malApi.idPrefix + "Simkl" -> simklApi.idPrefix + "AniList" -> aniListApi.idPrefix + else -> syncName + } + return repos.firstOrNull { it.idPrefix == realName }?.idPrefix + } + + fun setSync(syncName : String, syncId : String) { + syncs.clear() + syncs[syncName] = syncId + } + + fun clear() { + syncs.clear() + _metaResponse.postValue(null) + _currentSynced.postValue(getMissing()) + _userDataResponse.postValue(null) + } + fun updateMetaAndUser() { + _userDataResponse.postValue(Resource.Loading()) + _metaResponse.postValue(Resource.Loading()) + Log.i(TAG, "updateMetaAndUser") updateMetadata() updateUserData() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt index 81ef8d57..70919943 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.result import android.content.Context +import android.graphics.Bitmap import android.util.Log import android.widget.ImageView import android.widget.TextView @@ -8,9 +9,8 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible -import com.lagradost.cloudstream3.mvvm.Some import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.html +import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.UIHelper.setImage sealed class UiText { @@ -20,6 +20,13 @@ sealed class UiText { data class DynamicString(val value: String) : UiText() { override fun toString(): String = value + + override fun equals(other: Any?): Boolean { + if (other !is DynamicString) return false + return this.value == other.value + } + + override fun hashCode(): Int = value.hashCode() } class StringResource( @@ -28,6 +35,16 @@ sealed class UiText { ) : UiText() { override fun toString(): String = "resId = $resId\nargs = ${args.toList().map { "(${it::class} = $it)" }}" + override fun equals(other: Any?): Boolean { + if (other !is StringResource) return false + return this.resId == other.resId && this.args == other.args + } + + override fun hashCode(): Int { + var result = resId + result = 31 * result + args.hashCode() + return result + } } fun asStringNull(context: Context?): String? { @@ -68,12 +85,14 @@ sealed class UiImage { ) : UiImage() data class Drawable(@DrawableRes val resId: Int) : UiImage() + data class Bitmap(val bitmap: android.graphics.Bitmap) : UiImage() } fun ImageView?.setImage(value: UiImage?, fadeIn: Boolean = true) { when (value) { - is UiImage.Image -> setImageImage(value,fadeIn) + is UiImage.Image -> setImageImage(value, fadeIn) is UiImage.Drawable -> setImageDrawable(value) + is UiImage.Bitmap -> setImageBitmap(value) null -> { this?.isVisible = false } @@ -88,7 +107,13 @@ fun ImageView?.setImageImage(value: UiImage.Image, fadeIn: Boolean = true) { fun ImageView?.setImageDrawable(value: UiImage.Drawable) { if (this == null) return this.isVisible = true - setImageResource(value.resId) + this.setImage(UiImage.Drawable(value.resId)) +} + +fun ImageView?.setImageBitmap(value: UiImage.Bitmap) { + if (this == null) return + this.isVisible = true + this.setImageBitmap(value.bitmap) } @JvmName("imgNull") @@ -113,6 +138,10 @@ fun img(@DrawableRes drawable: Int): UiImage { return UiImage.Drawable(drawable) } +fun img(bitmap: Bitmap): UiImage { + return UiImage.Bitmap(bitmap) +} + fun txt(value: String): UiText { return UiText.DynamicString(value) } @@ -162,11 +191,3 @@ fun TextView?.setTextHtml(text: UiText?) { this.text = str.html() } } - -fun TextView?.setTextHtml(text: Some?) { - setTextHtml(if (text is Some.Success) text.value else null) -} - -fun TextView?.setText(text: Some?) { - setText(if (text is Some.Success) text.value else null) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt index 649641c8..f318401c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt @@ -4,16 +4,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.ImageView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.R +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.databinding.SearchResultGridBinding +import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.ui.AutofitRecyclerView -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout +import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.search_result_compact.view.* import kotlin.math.roundToInt /** Click */ @@ -39,10 +38,23 @@ class SearchAdapter( var hasNext: Boolean = false override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val layout = - if (parent.context.IsBottomLayout()) R.layout.search_result_grid_expanded else R.layout.search_result_grid + if (parent.context.isBottomLayout()) SearchResultGridExpandedBinding.inflate( + inflater, + parent, + false + ) else SearchResultGridBinding.inflate( + inflater, + parent, + false + ) //R.layout.search_result_grid_expanded else R.layout.search_result_grid + + + return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), + layout, clickCallback, resView ) @@ -71,22 +83,26 @@ class SearchAdapter( diffResult.dispatchUpdatesTo(this) } - class CardViewHolder - constructor( - itemView: View, + class CardViewHolder( + val binding: ViewBinding, private val clickCallback: (SearchClickCallback) -> Unit, resView: AutofitRecyclerView ) : - RecyclerView.ViewHolder(itemView) { - val cardView: ImageView = itemView.imageView + RecyclerView.ViewHolder(binding.root) { private val compactView = false//itemView.context.getGridIsCompact() private val coverHeight: Int = if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() + private val cardView = when(binding) { + is SearchResultGridExpandedBinding -> binding.imageView + is SearchResultGridBinding -> binding.imageView + else -> null + } + fun bind(card: SearchResponse, position: Int) { if (!compactView) { - cardView.apply { + cardView?.apply { layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, coverHeight diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index b4a38216..ef10fcee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui.search import android.content.DialogInterface import android.content.res.Configuration import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,6 +12,7 @@ import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.ListView +import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible @@ -22,47 +24,53 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.APIHolder.getApiSettings -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.AnimeSearchResponse +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.FragmentSearchBinding +import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.currentSpan import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.updateChips import com.lagradost.cloudstream3.ui.home.ParentItemAdapter -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.ownHide -import com.lagradost.cloudstream3.utils.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide +import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import kotlinx.android.synthetic.main.fragment_search.* -import kotlinx.android.synthetic.main.tvtypes_chips.* import java.util.concurrent.locks.ReentrantLock -const val SEARCH_PREF_TAGS = "search_pref_tags" -const val SEARCH_PREF_PROVIDERS = "search_pref_providers" - class SearchFragment : Fragment() { companion object { fun List.filterSearchResponse(): List { @@ -82,13 +90,14 @@ class SearchFragment : Fragment() { fun newInstance(query: String): Bundle { return Bundle().apply { - putString(SEARCH_QUERY, query) + if(query.isNotBlank()) putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() private var bottomSheetDialog: BottomSheetDialog? = null + var binding: FragmentSearchBinding? = null override fun onCreateView( inflater: LayoutInflater, @@ -99,18 +108,24 @@ class SearchFragment : Fragment() { WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() - return inflater.inflate( - if (isTvSettings()) R.layout.fragment_search_tv else R.layout.fragment_search, - container, - false - ) + + + binding = try { + val layout = if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search + val root = inflater.inflate(layout, container, false) + FragmentSearchBinding.bind(root) + } catch (t : Throwable) { + FragmentSearchBinding.inflate(inflater) + } + + return binding?.root } private fun fixGrid() { activity?.getSpanCount()?.let { currentSpan = it } - search_autofit_results.spanCount = currentSpan + binding?.searchAutofitResults?.spanCount = currentSpan currentSpan = currentSpan HomeFragment.configEvent.invoke(currentSpan) } @@ -123,6 +138,7 @@ class SearchFragment : Fragment() { override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() + binding = null super.onDestroyView() } @@ -146,7 +162,8 @@ class SearchFragment : Fragment() { **/ fun search(query: String?) { if (query == null) return - + // don't resume state from prev search + (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*,*>)?.clear() context?.let { ctx -> val default = enumValues().sorted().filter { it != TvType.NSFW } .map { it.ordinal.toString() }.toSet() @@ -181,15 +198,15 @@ class SearchFragment : Fragment() { searchViewModel.reloadRepos() context?.filterProviderByPreferredMedia()?.let { validAPIs -> bindChips( - home_select_group, + binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes, validAPIs.flatMap { api -> api.supportedTypes }.distinct() ) { list -> if (selectedSearchTypes.toSet() != list.toSet()) { - setKey(SEARCH_PREF_TAGS, selectedSearchTypes) + DataStoreHelper.searchPreferenceTags = list selectedSearchTypes.clear() selectedSearchTypes.addAll(list) - search(main_search?.query?.toString()) + search(binding?.mainSearch?.query?.toString()) } } } @@ -199,38 +216,41 @@ class SearchFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(searchRoot) + fixPaddingStatusbar(binding?.searchRoot) fixGrid() reloadRepos() - val adapter: RecyclerView.Adapter? = activity?.let { - SearchAdapter( - ArrayList(), - search_autofit_results, - ) { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) - } + binding?.apply { + val adapter: RecyclerView.Adapter = + SearchAdapter( + ArrayList(), + searchAutofitResults, + ) { callback -> + SearchHelper.handleSearchClickCallback(callback) + } + + searchRoot.findViewById(R.id.search_src_text)?.tag = "tv_no_focus_tag" + searchAutofitResults.adapter = adapter + searchLoadingBar.alpha = 0f } - search_autofit_results.adapter = adapter - search_loading_bar.alpha = 0f val searchExitIcon = - main_search.findViewById(androidx.appcompat.R.id.search_close_btn) + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) // val searchMagIcon = - // main_search.findViewById(androidx.appcompat.R.id.search_mag_icon) - //searchMagIcon.scaleX = 0.65f - //searchMagIcon.scaleY = 0.65f + // binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) + // searchMagIcon.scaleX = 0.65f + // searchMagIcon.scaleY = 0.65f - context?.let { ctx -> - val validAPIs = ctx.filterProviderByPreferredMedia() - selectedApis = ctx.getKey( - SEARCH_PREF_PROVIDERS, - defVal = validAPIs.map { it.name } - )!!.toMutableSet() - } + // Set the color for the search exit icon to the correct theme text color + val searchExitIconColor = TypedValue() - search_filter.setOnClickListener { searchView -> + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) + + selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() + + binding?.searchFilter?.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() @@ -241,9 +261,18 @@ class SearchFragment : Fragment() { BottomSheetDialog(ctx) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - builder.setContentView(R.layout.home_select_mainpage) + + val selectMainpageBinding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( + builder.layoutInflater, + null, + false + ) + builder.setContentView(selectMainpageBinding.root) builder.show() builder.let { dialog -> + val previousSelectedApis = selectedApis.toSet() + val previousSelectedSearchTypes = selectedSearchTypes.toSet() + val isMultiLang = ctx.getApiProviderLangSettings().let { set -> set.size > 1 || set.contains(AllLanguagesName) } @@ -270,7 +299,7 @@ class SearchFragment : Fragment() { } fun updateList(types: List) { - setKey(SEARCH_PREF_TAGS, types.map { it.name }) + DataStoreHelper.searchPreferenceTags = types arrayAdapter.clear() currentValidApis = validAPIs.filter { api -> @@ -295,21 +324,23 @@ class SearchFragment : Fragment() { arrayAdapter.notifyDataSetChanged() } - val selectedSearchTypes = getKey>(SEARCH_PREF_TAGS) - ?.mapNotNull { listName -> - TvType.values().firstOrNull { it.name == listName } - } - ?.toMutableList() - ?: mutableListOf(TvType.Movie, TvType.TvSeries) - bindChips( - dialog.home_select_group, + selectMainpageBinding.tvtypesChipsScroll.tvtypesChips, selectedSearchTypes, - TvType.values().toList() + validAPIs.flatMap { api -> api.supportedTypes }.distinct() ) { list -> updateList(list) + + // refresh selected chips in main chips + if (selectedSearchTypes.toSet() != list.toSet()) { + selectedSearchTypes.clear() + selectedSearchTypes.addAll(list) + updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes) + + } } + cancelBtt?.setOnClickListener { dialog.dismissSafe() } @@ -326,8 +357,13 @@ class SearchFragment : Fragment() { } dialog.setOnDismissListener { - context?.setKey(SEARCH_PREF_PROVIDERS, currentSelectedApis.toList()) + DataStoreHelper.searchPreferenceProviders = currentSelectedApis.toList() selectedApis = currentSelectedApis + + // run search when dialog is close + if(previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { + search(binding?.mainSearch?.query?.toString()) + } } updateList(selectedSearchTypes.toList()) } @@ -337,21 +373,18 @@ class SearchFragment : Fragment() { val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true - selectedSearchTypes = context?.getKey>(SEARCH_PREF_TAGS) - ?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } - ?.toMutableList() - ?: mutableListOf(TvType.Movie, TvType.TvSeries) + selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() - if (isTrueTvSettings()) { - search_filter.isFocusable = true - search_filter.isFocusableInTouchMode = true + if (isLayout(TV)) { + binding?.searchFilter?.isFocusable = true + binding?.searchFilter?.isFocusableInTouchMode = true } - main_search.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) - main_search?.let { + binding?.mainSearch?.let { hideKeyboard(it) } @@ -365,24 +398,24 @@ class SearchFragment : Fragment() { searchViewModel.clearSearch() searchViewModel.updateHistory() } - - search_history_holder?.isVisible = showHistory - - search_master_recycler?.isVisible = !showHistory && isAdvancedSearch - search_autofit_results?.isVisible = !showHistory && !isAdvancedSearch + binding?.apply { + searchHistoryHolder.isVisible = showHistory + searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch + searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch + } return true } }) - search_clear_call_history?.setOnClickListener { + binding?.searchClearCallHistory?.setOnClickListener { activity?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { - removeKeys(SEARCH_HISTORY_KEY) + removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") searchViewModel.updateHistory() } DialogInterface.BUTTON_NEGATIVE -> { @@ -409,8 +442,8 @@ class SearchFragment : Fragment() { } observe(searchViewModel.currentHistory) { list -> - search_clear_call_history?.isVisible = list.isNotEmpty() - (search_history_recycler.adapter as? SearchHistoryAdaptor?)?.updateList(list) + binding?.searchClearCallHistory?.isVisible = list.isNotEmpty() + (binding?.searchHistoryRecycler?.adapter as? SearchHistoryAdaptor?)?.updateList(list) } searchViewModel.updateHistory() @@ -420,20 +453,20 @@ class SearchFragment : Fragment() { is Resource.Success -> { it.value.let { data -> if (data.isNotEmpty()) { - (search_autofit_results?.adapter as? SearchAdapter)?.updateList(data) + (binding?.searchAutofitResults?.adapter as? SearchAdapter)?.updateList(data) } } - searchExitIcon.alpha = 1f - search_loading_bar.alpha = 0f + searchExitIcon?.alpha = 1f + binding?.searchLoadingBar?.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() - searchExitIcon.alpha = 1f - search_loading_bar.alpha = 0f + searchExitIcon?.alpha = 1f + binding?.searchLoadingBar?.alpha = 0f } is Resource.Loading -> { - searchExitIcon.alpha = 0f - search_loading_bar.alpha = 1f + searchExitIcon?.alpha = 0f + binding?.searchLoadingBar?.alpha = 1f } } } @@ -443,7 +476,7 @@ class SearchFragment : Fragment() { try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (search_master_recycler?.adapter as ParentItemAdapter?)?.apply { + (binding?.searchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { val newItems = list.map { ongoing -> val dataList = if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() @@ -475,9 +508,9 @@ class SearchFragment : Fragment() { }*/ //main_search.onActionViewExpanded()*/ - val masterAdapter: RecyclerView.Adapter = - ParentItemAdapter(mutableListOf(), { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + val masterAdapter = + ParentItemAdapter(fragment = this, id = "masterAdapter".hashCode(), { callback -> + SearchHelper.handleSearchClickCallback(callback) }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null @@ -490,11 +523,11 @@ class SearchFragment : Fragment() { SEARCH_HISTORY_OPEN -> { searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) - updateChips(home_select_group, searchItem.type.toMutableList()) - main_search?.setQuery(searchItem.searchText, true) + updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, searchItem.type.toMutableList()) + binding?.mainSearch?.setQuery(searchItem.searchText, true) } SEARCH_HISTORY_REMOVE -> { - removeKey(SEARCH_HISTORY_KEY, searchItem.key) + removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } else -> { @@ -503,20 +536,33 @@ class SearchFragment : Fragment() { } } - search_history_recycler?.adapter = historyAdapter - search_history_recycler?.layoutManager = GridLayoutManager(context, 1) + binding?.apply { + searchHistoryRecycler.adapter = historyAdapter + searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) + //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) - search_master_recycler?.adapter = masterAdapter - search_master_recycler?.layoutManager = GridLayoutManager(context, 1) + searchMasterRecycler.adapter = masterAdapter + //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) - // Automatically search the specified query, this allows the app search to launch from intent - arguments?.getString(SEARCH_QUERY)?.let { query -> - if (query.isBlank()) return@let - main_search?.setQuery(query, true) - // Clear the query as to not make it request the same query every time the page is opened - arguments?.putString(SEARCH_QUERY, null) + searchMasterRecycler.layoutManager = GridLayoutManager(context, 1) + + // Automatically search the specified query, this allows the app search to launch from intent + var sq = arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) + if(sq.isNullOrBlank()) { + sq = MainActivity.nextSearchQuery + } + + sq?.let { query -> + if (query.isBlank()) return@let + mainSearch.setQuery(query, true) + // Clear the query as to not make it request the same query every time the page is opened + arguments?.remove(SEARCH_QUERY) + savedInstanceState?.remove(SEARCH_QUERY) + MainActivity.nextSearchQuery = null + } } + // SubtitlesFragment.push(activity) //searchViewModel.search("iron man") //(activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") @@ -530,4 +576,4 @@ class SearchFragment : Fragment() { .commit()*/ } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt index 45336d5b..ef1b8719 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.ui.search -import android.app.Activity import android.widget.Toast +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -9,61 +9,61 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper object SearchHelper { - fun handleSearchClickCallback(activity: Activity?, callback: SearchClickCallback) { + fun handleSearchClickCallback(callback: SearchClickCallback) { val card = callback.card when (callback.action) { SEARCH_ACTION_LOAD -> { - activity.loadSearchResult(card) + loadSearchResult(card) } SEARCH_ACTION_PLAY_FILE -> { if (card is DataStoreHelper.ResumeWatchingResult) { val id = card.id - if(id == null) { - showToast(activity, R.string.error_invalid_id, Toast.LENGTH_SHORT) + if (id == null) { + showToast(R.string.error_invalid_id, Toast.LENGTH_SHORT) } else { if (card.isFromDownload) { handleDownloadClick( - activity, DownloadClickEvent( + DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, VideoDownloadHelper.DownloadEpisodeCached( - card.name, - card.posterUrl, - card.episode ?: 0, - card.season, - id, - card.parentId ?: return, - null, - null, - System.currentTimeMillis() + name = card.name, + poster = card.posterUrl, + episode = card.episode ?: 0, + season = card.season, + id = id, + parentId = card.parentId ?: return, + rating = null, + description = null, + cacheTime = System.currentTimeMillis(), ) ) ) } else { - activity.loadSearchResult(card, START_ACTION_LOAD_EP, id) + loadSearchResult(card, START_ACTION_LOAD_EP, id) } } } else { handleSearchClickCallback( - activity, SearchClickCallback(SEARCH_ACTION_LOAD, callback.view, -1, callback.card) ) } } SEARCH_ACTION_SHOW_METADATA -> { - if(!isTvSettings()) { // we only want this on phone as UI is not done yet on tv + if(isLayout(PHONE)) { // we only want this on phone as UI is not done yet on tv (activity as? MainActivity?)?.apply { loadPopup(callback.card) } ?: kotlin.run { - showToast(activity, callback.card.name, Toast.LENGTH_SHORT) + showToast(callback.card.name, Toast.LENGTH_SHORT) } } else { - showToast(activity, callback.card.name, Toast.LENGTH_SHORT) + showToast(callback.card.name, Toast.LENGTH_SHORT) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt index 8132301b..4ef5fa69 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt @@ -1,16 +1,12 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import kotlinx.android.synthetic.main.search_history_item.view.* +import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -34,8 +30,7 @@ class SearchHistoryAdaptor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return CardViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.search_history_item, parent, false), + SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), clickCallback, ) } @@ -63,24 +58,25 @@ class SearchHistoryAdaptor( diffResult.dispatchUpdatesTo(this) } - class CardViewHolder - constructor( - itemView: View, + class CardViewHolder( + val binding: SearchHistoryItemBinding, private val clickCallback: (SearchHistoryCallback) -> Unit, ) : - RecyclerView.ViewHolder(itemView) { - private val removeButton: ImageView = itemView.home_history_remove - private val openButton: View = itemView.home_history_tab - private val title: TextView = itemView.home_history_title + RecyclerView.ViewHolder(binding.root) { + // private val removeButton: ImageView = itemView.home_history_remove + // private val openButton: View = itemView.home_history_tab + // private val title: TextView = itemView.home_history_title fun bind(card: SearchHistoryItem) { - title.text = card.searchText + binding.apply { + homeHistoryTitle.text = card.searchText - removeButton.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) - } - openButton.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) + homeHistoryRemove.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) + } + homeHistoryTab.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt index 3447ee32..92575e58 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.ui.search +import android.annotation.SuppressLint import android.content.Context -import android.graphics.drawable.Drawable import android.view.View import android.widget.ImageView import android.widget.ProgressBar @@ -10,14 +10,21 @@ import androidx.cardview.widget.CardView import androidx.core.view.isVisible import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull +import com.lagradost.cloudstream3.AnimeSearchResponse +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LiveSearchResponse +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.isMovieType +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.home_result_grid.view.* object SearchResultBuilder { private val showCache: MutableMap = mutableMapOf() @@ -31,33 +38,31 @@ object SearchResultBuilder { } } - /** - * @param nextFocusBehavior True if first, False if last, Null if between. - * Used to prevent escaping the adapter horizontally (focus wise). - */ + @SuppressLint("StringFormatInvalid") fun bind( clickCallback: (SearchClickCallback) -> Unit, card: SearchResponse, position: Int, itemView: View, - nextFocusBehavior: Boolean? = null, nextFocusUp: Int? = null, nextFocusDown: Int? = null, colorCallback : ((Palette) -> Unit)? = null ) { - val cardView: ImageView = itemView.imageView - val cardText: TextView? = itemView.imageText + val cardView: ImageView = itemView.findViewById(R.id.imageView) + val cardText: TextView? = itemView.findViewById(R.id.imageText) - val textIsDub: TextView? = itemView.text_is_dub - val textIsSub: TextView? = itemView.text_is_sub - val textFlag: TextView? = itemView.text_flag - val textQuality: TextView? = itemView.text_quality - val shadow: View? = itemView.title_shadow + val textIsDub: TextView? = itemView.findViewById(R.id.text_is_dub) + val textIsSub: TextView? = itemView.findViewById(R.id.text_is_sub) + val textFlag: TextView? = itemView.findViewById(R.id.text_flag) + val rating: TextView? = itemView.findViewById(R.id.text_rating) - val bg: CardView = itemView.background_card + val textQuality: TextView? = itemView.findViewById(R.id.text_quality) + val shadow: View? = itemView.findViewById(R.id.title_shadow) - val bar: ProgressBar? = itemView.watchProgress - val playImg: ImageView? = itemView.search_item_download_play + val bg: CardView = itemView.findViewById(R.id.background_card) + + val bar: ProgressBar? = itemView.findViewById(R.id.watchProgress) + val playImg: ImageView? = itemView.findViewById(R.id.search_item_download_play) // Do logic @@ -66,12 +71,25 @@ object SearchResultBuilder { textIsDub?.isVisible = false textIsSub?.isVisible = false textFlag?.isVisible = false + rating?.isVisible = false val showSub = showCache[textIsDub?.context?.getString(R.string.show_sub_key)] ?: false val showDub = showCache[textIsDub?.context?.getString(R.string.show_dub_key)] ?: false val showTitle = showCache[cardText?.context?.getString(R.string.show_title_key)] ?: false val showHd = showCache[textQuality?.context?.getString(R.string.show_hd_key)] ?: false + if(card is SyncAPI.LibraryItem) { + val showRating = (card.personalRating ?: 0) != 0 + rating?.isVisible = showRating + if (showRating) { + // We want to show 8.5 but not 8.0 hence the replace + val ratingText = ((card.personalRating ?: 0).toDouble() / 10).toString() + .replace(".0", "") + + rating?.text = ratingText + } + } + shadow?.isVisible = showTitle when (card.quality) { @@ -142,15 +160,42 @@ object SearchResultBuilder { } } - bg.setOnClickListener { - click(it) + bg.isFocusable = false + bg.isFocusableInTouchMode = false + if(!isLayout(TV)) { + bg.setOnClickListener { + click(it) + } + bg.setOnLongClickListener { + longClick(it) + return@setOnLongClickListener true + } } + // + // + // itemView.setOnClickListener { click(it) } - if (nextFocusUp != null) { + itemView.nextFocusUpId = nextFocusUp + } + + if (nextFocusDown != null) { + itemView.nextFocusDownId = nextFocusDown + } + + /*when (nextFocusBehavior) { + true -> itemView.nextFocusLeftId = bg.id + false -> itemView.nextFocusRightId = bg.id + null -> { + bg.nextFocusRightId = -1 + bg.nextFocusLeftId = -1 + } + }*/ + + /*if (nextFocusUp != null) { bg.nextFocusUpId = nextFocusUp } @@ -158,36 +203,26 @@ object SearchResultBuilder { bg.nextFocusDownId = nextFocusDown } - when (nextFocusBehavior) { - true -> bg.nextFocusLeftId = bg.id - false -> bg.nextFocusRightId = bg.id - null -> { - bg.nextFocusRightId = -1 - bg.nextFocusLeftId = -1 - } - } + */ - if (isTrueTvSettings()) { - bg.isFocusable = true - bg.isFocusableInTouchMode = true - bg.touchscreenBlocksFocus = false + if (isLayout(TV)) { + // bg.isFocusable = true + // bg.isFocusableInTouchMode = true + // bg.touchscreenBlocksFocus = false itemView.isFocusableInTouchMode = true itemView.isFocusable = true } - bg.setOnLongClickListener { - longClick(it) - return@setOnLongClickListener true - } + /**/ itemView.setOnLongClickListener { longClick(it) return@setOnLongClickListener true } - bg.setOnFocusChangeListener { view, b -> + /*bg.setOnFocusChangeListener { view, b -> focus(view, b) - } + }*/ itemView.setOnFocusChangeListener { view, b -> focus(view, b) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index aceda644..839b9d3f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -14,6 +14,7 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -37,7 +38,7 @@ class SearchViewModel : ViewModel() { private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory - private var repos = apis.map { APIRepository(it) } + private var repos = synchronized(apis) { apis.map { APIRepository(it) } } fun clearSearch() { _searchResponse.postValue(Resource.Success(ArrayList())) @@ -48,7 +49,7 @@ class SearchViewModel : ViewModel() { private var onGoingSearch: Job? = null fun reloadRepos() { - repos = apis.map { APIRepository(it) } + repos = synchronized(apis) { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -64,7 +65,7 @@ class SearchViewModel : ViewModel() { fun updateHistory() = viewModelScope.launch { ioSafe { - val items = getKeys(SEARCH_HISTORY_KEY)?.mapNotNull { + val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { getKey(it) }?.sortedByDescending { it.searchedAt } ?: emptyList() _currentHistory.postValue(items) @@ -87,7 +88,7 @@ class SearchViewModel : ViewModel() { if (!isQuickSearch) { val key = query.hashCode().toString() setKey( - SEARCH_HISTORY_KEY, + "$currentAccount/$SEARCH_HISTORY_KEY", key, SearchHistoryItem( searchedAt = System.currentTimeMillis(), @@ -140,4 +141,4 @@ class SearchViewModel : ViewModel() { _searchResponse.postValue(Resource.Success(list)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt index 9e03079f..71077e91 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt @@ -3,11 +3,9 @@ package com.lagradost.cloudstream3.ui.search import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +//TODO Relevance of this class since it's not used class SyncSearchViewModel { - private val repos = SyncApis - data class SyncSearchResultSearchResponse( override val name: String, override val url: String, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt index e879f0df..d7bd69f1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt @@ -1,28 +1,29 @@ package com.lagradost.cloudstream3.ui.settings +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AccountSingleBinding import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.utils.UIHelper.setImage class AccountClickCallback(val action: Int, val view: View, val card: AuthAPI.LoginInfo) class AccountAdapter( - val cardList: List, - val layout: Int = R.layout.account_single, + private val cardList: List, private val clickCallback: (AccountClickCallback) -> Unit ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), clickCallback + AccountSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false), //LayoutInflater.from(parent.context).inflate(layout, parent, false), + + clickCallback ) } @@ -42,19 +43,19 @@ class AccountAdapter( return cardList[position].accountIndex.toLong() } - class CardViewHolder - constructor(itemView: View, private val clickCallback: (AccountClickCallback) -> Unit) : - RecyclerView.ViewHolder(itemView) { - private val pfp: ImageView = itemView.findViewById(R.id.account_profile_picture)!! - private val accountName: TextView = itemView.findViewById(R.id.account_name)!! + class CardViewHolder(val binding: AccountSingleBinding, private val clickCallback: (AccountClickCallback) -> Unit) : + RecyclerView.ViewHolder(binding.root) { + // private val pfp: ImageView = itemView.findViewById(R.id.account_profile_picture)!! + // private val accountName: TextView = itemView.findViewById(R.id.account_name)!! + @SuppressLint("StringFormatInvalid") fun bind(card: AuthAPI.LoginInfo) { // just in case name is null account index will show, should never happened - accountName.text = card.name ?: "%s %d".format( - accountName.context.getString(R.string.account), + binding.accountName.text = card.name ?: "%s %d".format( + binding.accountName.context.getString(R.string.account), card.accountIndex ) - pfp.isVisible = pfp.setImage(card.profilePicture) + binding.accountProfilePicture.isVisible = binding.accountProfilePicture.setImage(card.profilePicture) itemView.setOnClickListener { clickCallback.invoke(AccountClickCallback(0, itemView, card)) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt new file mode 100644 index 00000000..aa513d87 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt @@ -0,0 +1,56 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R + +object Globals { + var beneneCount = 0 + + const val PHONE : Int = 0b001 + const val TV : Int = 0b010 + const val EMULATOR : Int = 0b100 + private const val INVALID = -1 + private var layoutId = INVALID + + private fun Context.getLayoutInt(): Int { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + return settingsManager.getInt(this.getString(R.string.app_layout_key), -1) + } + + private fun Context.isAutoTv(): Boolean { + val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? + // AFT = Fire TV + val model = Build.MODEL.lowercase() + return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION || Build.MODEL.contains( + "AFT" + ) || model.contains("firestick") || model.contains("fire tv") || model.contains("chromecast") + } + + private fun Context.layoutIntCorrected(): Int { + return when(getLayoutInt()) { + -1 -> if (isAutoTv()) TV else PHONE + 0 -> PHONE + 1 -> TV + 2 -> EMULATOR + else -> PHONE + } + } + + fun Context.updateTv() { + layoutId = layoutIntCorrected() + } + + /** Returns true if the layout is any of the flags, + * so isLayout(TV or EMULATOR) is a valid statement for checking if the layout is in the emulator + * or tv. Auto will become the "TV" or the "PHONE" layout. + * + * Valid flags are: PHONE, TV, EMULATOR + * */ + fun isLayout(flags: Int) : Boolean { + return (layoutId and flags) != 0 + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index f9627e46..15f8735f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -1,8 +1,10 @@ package com.lagradost.cloudstream3.ui.settings +import android.graphics.Bitmap import android.os.Bundle +import android.os.CountDownTimer import android.view.View -import android.view.View.* +import android.view.View.FOCUS_DOWN import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.annotation.UiThread @@ -11,31 +13,58 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceManager +import androidx.preference.SwitchPreferenceCompat import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AccountManagmentBinding +import com.lagradost.cloudstream3.databinding.AccountSwitchBinding +import com.lagradost.cloudstream3.databinding.AddAccountInputBinding +import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.ui.result.img +import com.lagradost.cloudstream3.ui.result.setImage +import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.authCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.account_managment.* -import kotlinx.android.synthetic.main.account_switch.* -import kotlinx.android.synthetic.main.add_account_input.* +import qrcode.QRCode -class SettingsAccount : PreferenceFragmentCompat() { +class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ fun showLoginInfo( @@ -43,15 +72,18 @@ class SettingsAccount : PreferenceFragmentCompat() { api: AccountManager, info: AuthAPI.LoginInfo ) { + if (activity == null) return + val binding: AccountManagmentBinding = + AccountManagmentBinding.inflate(activity.layoutInflater, null, false) val builder = - AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) - .setView(R.layout.account_managment) + AlertDialog.Builder(activity, R.style.AlertDialogCustom) + .setView(binding.root) val dialog = builder.show() - dialog.account_main_profile_picture_holder?.isVisible = - dialog.account_main_profile_picture?.setImage(info.profilePicture) == true + binding.accountMainProfilePictureHolder.isVisible = + binding.accountMainProfilePicture.setImage(info.profilePicture) - dialog.account_logout?.setOnClickListener { + binding.accountLogout.setOnClickListener { api.logOut() dialog.dismissSafe(activity) } @@ -60,26 +92,28 @@ class SettingsAccount : PreferenceFragmentCompat() { dialog.findViewById(R.id.account_name)?.text = it } - dialog.account_site?.text = api.name - dialog.account_switch_account?.setOnClickListener { + binding.accountSite.text = api.name + binding.accountSwitchAccount.setOnClickListener { dialog.dismissSafe(activity) showAccountSwitch(activity, api) } - if (isTvSettings()) { - dialog.account_switch_account?.requestFocus() + if (isLayout(TV or EMULATOR)) { + binding.accountSwitchAccount.requestFocus() } } - fun showAccountSwitch(activity: FragmentActivity, api: AccountManager) { + private fun showAccountSwitch(activity: FragmentActivity, api: AccountManager) { val accounts = api.getAccounts() ?: return + val binding: AccountSwitchBinding = + AccountSwitchBinding.inflate(activity.layoutInflater, null, false) val builder = AlertDialog.Builder(activity, R.style.AlertDialogCustom) - .setView(R.layout.account_switch) + .setView(binding.root) val dialog = builder.show() - dialog.account_add?.setOnClickListener { + binding.accountAdd.setOnClickListener { addAccount(activity, api) dialog?.dismissSafe(activity) } @@ -96,7 +130,7 @@ class SettingsAccount : PreferenceFragmentCompat() { } } api.accountIndex = ogIndex - val adapter = AccountAdapter(items, R.layout.account_single) { + val adapter = AccountAdapter(items) { dialog?.dismissSafe(activity) api.changeAccount(it.card.accountIndex) } @@ -109,22 +143,128 @@ class SettingsAccount : PreferenceFragmentCompat() { try { when (api) { is OAuth2API -> { - api.authenticate(activity) + if (isLayout(PHONE) || !api.supportDeviceAuth) { + api.authenticate(activity) + } else if (api.supportDeviceAuth && activity != null) { + + val binding: DeviceAuthBinding = + DeviceAuthBinding.inflate(activity.layoutInflater, null, false) + + val builder = + AlertDialog.Builder(activity) + .setView(binding.root) + + builder.apply { + setNegativeButton(R.string.cancel) { _, _ -> } + setPositiveButton(R.string.auth_locally) { _, _ -> + api.authenticate(activity) + } + } + + val dialog = builder.create() + + ioSafe { + try { + val pinCodeData = api.getDevicePin() + if (pinCodeData == null) { + showToast(R.string.device_pin_error_message) + api.authenticate(activity) + return@ioSafe + } + + /*val logoBytes = ContextCompat.getDrawable( + activity, + R.drawable.cloud_2_solid + )?.toBitmapOrNull()?.let { bitmap -> + val csLogo = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, csLogo) + csLogo.toByteArray() + }*/ + + val qrCodeImage = QRCode.ofRoundedSquares() + .withColor(activity.colorFromAttribute(R.attr.textColor)) + .withBackgroundColor(activity.colorFromAttribute(R.attr.primaryBlackBackground)) + //.withLogo(logoBytes, 200.toPx, 200.toPx) //For later if logo needed anytime + .build(pinCodeData.verificationUrl) + .render().nativeImage() as Bitmap + + activity.runOnUiThread { + dialog.show() + binding.apply { + devicePinCode.setText(txt(pinCodeData.userCode)) + deviceAuthMessage.setText( + txt( + R.string.device_pin_url_message, + pinCodeData.verificationUrl + ) + ) + deviceAuthQrcode.setImage( + img(qrCodeImage) + ) + } + + val expirationMillis = + pinCodeData.expiresIn.times(1000).toLong() + + object : CountDownTimer(expirationMillis, 1000) { + + override fun onTick(millisUntilFinished: Long) { + val secondsUntilFinished = + millisUntilFinished.div(1000).toInt() + + binding.deviceAuthValidationCounter.setText( + txt( + R.string.device_pin_counter_text, + secondsUntilFinished.div(60), + secondsUntilFinished.rem(60) + ) + ) + + ioSafe { + if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.handleDeviceAuth(pinCodeData)) { + showToast( + txt( + R.string.authenticated_user, + api.name + ) + ) + dialog.dismissSafe(activity) + cancel() + } + } + } + + override fun onFinish() { + showToast(R.string.device_pin_expired_message) + dialog.dismissSafe(activity) + } + + }.start() + } + } catch (e: Exception) { + logError(e) + } + } + } } + is InAppAuthAPI -> { + if (activity == null) return + val binding: AddAccountInputBinding = + AddAccountInputBinding.inflate(activity.layoutInflater, null, false) val builder = - AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) - .setView(R.layout.add_account_input) + AlertDialog.Builder(activity, R.style.AlertDialogCustom) + .setView(binding.root) val dialog = builder.show() - val visibilityMap = mapOf( - dialog.login_email_input to api.requiresEmail, - dialog.login_password_input to api.requiresPassword, - dialog.login_server_input to api.requiresServer, - dialog.login_username_input to api.requiresUsername + val visibilityMap = listOf( + binding.loginEmailInput to api.requiresEmail, + binding.loginPasswordInput to api.requiresPassword, + binding.loginServerInput to api.requiresServer, + binding.loginUsernameInput to api.requiresUsername ) - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { visibilityMap.forEach { (input, isVisible) -> input.isVisible = isVisible @@ -145,63 +285,77 @@ class SettingsAccount : PreferenceFragmentCompat() { } } - dialog.login_email_input?.isVisible = api.requiresEmail - dialog.login_password_input?.isVisible = api.requiresPassword - dialog.login_server_input?.isVisible = api.requiresServer - dialog.login_username_input?.isVisible = api.requiresUsername - dialog.create_account?.isGone = api.createAccountUrl.isNullOrBlank() - dialog.create_account?.setOnClickListener { + binding.loginEmailInput.isVisible = api.requiresEmail + binding.loginPasswordInput.isVisible = api.requiresPassword + binding.loginServerInput.isVisible = api.requiresServer + binding.loginUsernameInput.isVisible = api.requiresUsername + binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank() + binding.createAccount.setOnClickListener { openBrowser( api.createAccountUrl ?: return@setOnClickListener, activity ) dialog.dismissSafe() } - dialog.text1?.text = api.name + + val displayedItems = listOf( + binding.loginUsernameInput, + binding.loginEmailInput, + binding.loginServerInput, + binding.loginPasswordInput + ).filter { it.isVisible } + + displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> + item.id.let { previous?.nextFocusDownId = it } + previous?.id?.let { item.nextFocusUpId = it } + item + } + + displayedItems.firstOrNull()?.let { + binding.createAccount.nextFocusDownId = it.id + it.nextFocusUpId = binding.createAccount.id + } + binding.applyBtt.id.let { + displayedItems.lastOrNull()?.nextFocusDownId = it + } + + binding.text1.text = api.name if (api.storesPasswordInPlainText) { api.getLatestLoginData()?.let { data -> - dialog.login_email_input?.setText(data.email ?: "") - dialog.login_server_input?.setText(data.server ?: "") - dialog.login_username_input?.setText(data.username ?: "") - dialog.login_password_input?.setText(data.password ?: "") + binding.loginEmailInput.setText(data.email ?: "") + binding.loginServerInput.setText(data.server ?: "") + binding.loginUsernameInput.setText(data.username ?: "") + binding.loginPasswordInput.setText(data.password ?: "") } } - dialog.apply_btt?.setOnClickListener { + binding.applyBtt.setOnClickListener { val loginData = InAppAuthAPI.LoginData( - username = if (api.requiresUsername) dialog.login_username_input?.text?.toString() else null, - password = if (api.requiresPassword) dialog.login_password_input?.text?.toString() else null, - email = if (api.requiresEmail) dialog.login_email_input?.text?.toString() else null, - server = if (api.requiresServer) dialog.login_server_input?.text?.toString() else null, + username = if (api.requiresUsername) binding.loginUsernameInput.text?.toString() else null, + password = if (api.requiresPassword) binding.loginPasswordInput.text?.toString() else null, + email = if (api.requiresEmail) binding.loginEmailInput.text?.toString() else null, + server = if (api.requiresServer) binding.loginServerInput.text?.toString() else null, ) ioSafe { - val isSuccessful = try { - api.login(loginData) + try { + showToast( + txt( + if (api.login(loginData)) R.string.authenticated_user else R.string.authenticated_user_fail, + api.name + ) + ) } catch (e: Exception) { logError(e) - false - } - activity.runOnUiThread { - try { - showToast( - activity, - activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail) - .format( - api.name - ) - ) - } catch (e: Exception) { - logError(e) // format might fail - } } } dialog.dismissSafe(activity) } - dialog.cancel_btt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } + else -> { throw NotImplementedError("You are trying to add an account that has an unknown login method") } @@ -212,27 +366,75 @@ class SettingsAccount : PreferenceFragmentCompat() { } } + private fun updateAuthPreference(enabled: Boolean) { + val biometricKey = getString(R.string.biometric_key) + + PreferenceManager.getDefaultSharedPreferences(context ?: return).edit() + .putBoolean(biometricKey, enabled).apply() + findPreference(biometricKey)?.isChecked = enabled + } + + override fun onAuthenticationError() { + updateAuthPreference(!isAuthEnabled(context ?: return)) + } + + override fun onAuthenticationSuccess() { + if (isAuthEnabled(context?: return)) { + updateAuthPreference(true) + BackupUtils.backup(activity) + activity?.showBottomDialogText( + getString(R.string.biometric_setting), + getString(R.string.biometric_warning).html() + ) { onDialogDismissedEvent } + } else { + updateAuthPreference(false) + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_account) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_account, rootKey) + //Hides the security category on TV as it's only Biometric for now + getPref(R.string.pref_category_security_key)?.hideOn(TV or EMULATOR) + + getPref(R.string.biometric_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false + + if (deviceHasPasswordPinLock(ctx)) { + startBiometricAuthentication( + activity?: return@setOnPreferenceClickListener false, + R.string.biometric_authentication_title, + false + ) + promptInfo?.let { + authCallback = this + biometricPrompt?.authenticate(it) + } + } + + false + } + val syncApis = listOf( R.string.mal_key to malApi, R.string.anilist_key to aniListApi, + R.string.simkl_key to simklApi, R.string.opensubtitles_key to openSubtitlesApi, + R.string.subdl_key to subDlApi, ) for ((key, api) in syncApis) { getPref(key)?.apply { - title = - getString(R.string.login_format).format(api.name, getString(R.string.account)) + title = api.name setOnPreferenceClickListener { val info = api.loginInfo() if (info != null) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 40c996cc..88335eea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -1,37 +1,46 @@ package com.lagradost.cloudstream3.ui.settings -import android.app.UiModeManager -import android.content.Context -import android.content.res.Configuration -import android.os.Build import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import androidx.annotation.StringRes -import androidx.core.view.isVisible +import androidx.core.view.children +import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceManager +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.MaterialToolbar +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers import com.lagradost.cloudstream3.ui.home.HomeFragment -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.main_settings.* -import kotlinx.android.synthetic.main.standard_toolbar.* import java.io.File +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone class SettingsFragment : Fragment() { companion object { - var beneneCount = 0 - - private var isTv : Boolean = false - private var isTrueTv : Boolean = false fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null @@ -45,36 +54,86 @@ class SettingsFragment : Fragment() { } /** - * On TV you cannot properly scroll to the bottom of settings, this fixes that. - * */ - fun PreferenceFragmentCompat.setPaddingBottom() { - if (isTvSettings()) { - listView?.setPadding(0, 0, 0, 100.toPx) + * Hide many Preferences on selected layouts. + **/ + fun PreferenceFragmentCompat?.hidePrefs(ids: List, layoutFlags: Int) { + if (this == null) return + + try { + ids.forEach { + getPref(it)?.isVisible = !isLayout(layoutFlags) + } + } catch (e: Exception) { + logError(e) } } - fun Fragment?.setUpToolbar(title: String) { - if (this == null) return - settings_toolbar?.apply { - setTitle(title) - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - activity?.onBackPressed() + /** + * Hide the Preference on selected layouts. + **/ + fun Preference?.hideOn(layoutFlags: Int): Preference? { + if (this == null) return null + this.isVisible = !isLayout(layoutFlags) + return this + } + + /** + * On TV you cannot properly scroll to the bottom of settings, this fixes that. + * */ + fun PreferenceFragmentCompat.setPaddingBottom() { + if (isLayout(TV or EMULATOR)) { + listView?.setPadding(0, 0, 0, 100.toPx) + } + } + fun PreferenceFragmentCompat.setToolBarScrollFlags() { + if (isLayout(TV or EMULATOR)) { + val settingsAppbar = view?.findViewById(R.id.settings_toolbar) + + settingsAppbar?.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } } - context.fixPaddingStatusbar(settings_toolbar) + } + fun Fragment?.setToolBarScrollFlags() { + if (isLayout(TV or EMULATOR)) { + val settingsAppbar = this?.view?.findViewById(R.id.settings_toolbar) + + settingsAppbar?.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL + } + } + } + fun Fragment?.setUpToolbar(title: String) { + if (this == null) return + val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return + + settingsToolbar.apply { + setTitle(title) + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + } + } + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { if (this == null) return - settings_toolbar?.apply { + val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return + + settingsToolbar.apply { setTitle(title) - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - activity?.onBackPressed() + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) + setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } } } - context.fixPaddingStatusbar(settings_toolbar) + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun getFolderSize(dir: File): Long { @@ -90,61 +149,21 @@ class SettingsFragment : Fragment() { return size } - - private fun Context.getLayoutInt(): Int { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - return settingsManager.getInt(this.getString(R.string.app_layout_key), -1) - } - - private fun Context.isTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 || value == 2 - } - - private fun Context.isTrueTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 - } - - fun Context.updateTv() { - isTrueTv = isTrueTvSettings() - isTv = isTvSettings() - } - - fun isTrueTvSettings(): Boolean { - return isTrueTv - } - - fun isTvSettings(): Boolean { - return isTv - } - - fun Context.isEmulatorSettings(): Boolean { - return getLayoutInt() == 2 - } - - private fun Context.isAutoTv(): Boolean { - val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? - // AFT = Fire TV - val model = Build.MODEL.lowercase() - return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION || Build.MODEL.contains( - "AFT" - ) || model.contains("firestick") || model.contains("fire tv") || model.contains("chromecast") - } + } + override fun onDestroyView() { + binding = null + super.onDestroyView() } + var binding: MainSettingsBinding? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.main_settings, container, false) + ): View { + val localBinding = MainSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -152,43 +171,82 @@ class SettingsFragment : Fragment() { activity?.navigate(id, Bundle()) } - val isTrueTv = isTrueTvSettings() + /** used to debug leaks + showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : + ${VideoDownloadManager.downloadProgressEvent.size}") **/ - for (syncApi in accountManagers) { - val login = syncApi.loginInfo() - val pic = login?.profilePicture ?: continue - if (settings_profile_pic?.setImage( - pic, - errorImageDrawable = HomeFragment.errorProfilePic - ) == true - ) { - settings_profile_text?.text = login.name - settings_profile?.isVisible = true - break + fun hasProfilePictureFromAccountManagers(accountManagers: List): Boolean { + for (syncApi in accountManagers) { + val login = syncApi.loginInfo() + val pic = login?.profilePicture ?: continue + + if (binding?.settingsProfilePic?.setImage( + pic, + errorImageDrawable = HomeFragment.errorProfilePic + ) == true + ) { + binding?.settingsProfileText?.text = login.name + return true // sync profile exists + } + } + return false // not syncing + } + + // display local account information if not syncing + if (!hasProfilePictureFromAccountManagers(accountManagers)) { + val activity = activity ?: return + val currentAccount = try { + DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } ?: activity.let { DataStoreHelper.getDefaultAccount(activity) } + + } catch (t: IllegalStateException) { + Log.e("AccountManager", "Activity not found", t) + null + } + + binding?.settingsProfilePic?.setImage(currentAccount?.image) + binding?.settingsProfileText?.text = currentAccount?.name + } + + binding?.apply { + listOf( + settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, + settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, + settingsCredits to R.id.action_navigation_global_to_navigation_settings_account, + settingsUi to R.id.action_navigation_global_to_navigation_settings_ui, + settingsProviders to R.id.action_navigation_global_to_navigation_settings_providers, + settingsUpdates to R.id.action_navigation_global_to_navigation_settings_updates, + settingsExtensions to R.id.action_navigation_global_to_navigation_settings_extensions, + ).forEach { (view, navigationId) -> + view.apply { + setOnClickListener { + navigate(navigationId) + } + if (isLayout(TV)) { + isFocusable = true + isFocusableInTouchMode = true + } + } + } + + // Default focus on TV + if (isLayout(TV)) { + settingsGeneral.requestFocus() } } - listOf( - Pair(settings_general, R.id.action_navigation_settings_to_navigation_settings_general), - Pair(settings_player, R.id.action_navigation_settings_to_navigation_settings_player), - Pair(settings_credits, R.id.action_navigation_settings_to_navigation_settings_account), - Pair(settings_ui, R.id.action_navigation_settings_to_navigation_settings_ui), - Pair(settings_providers, R.id.action_navigation_settings_to_navigation_settings_providers), - Pair(settings_updates, R.id.action_navigation_settings_to_navigation_settings_updates), - Pair( - settings_extensions, - R.id.action_navigation_settings_to_navigation_settings_extensions - ), - ).forEach { (view, navigationId) -> - view?.apply { - setOnClickListener { - navigate(navigationId) - } - if (isTrueTv) { - isFocusable = true - isFocusableInTouchMode = true - } - } + val appVersion = getString(R.string.app_version) + val commitInfo = getString(R.string.commit_hash) + val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, + Locale.getDefault() + ).apply { timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(BuildConfig.BUILD_DATE)).replace("UTC", "") + + binding?.buildDate?.text = buildTimestamp + binding?.appVersionInfo?.setOnLongClickListener { + clipboardHelper(txt(R.string.extension_version), "$appVersion $commitInfo $buildTimestamp") + true } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 649aa634..7cb1a848 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Environment import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -13,22 +12,31 @@ import androidx.appcompat.app.AlertDialog import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.databinding.AddRemoveSitesBinding +import com.lagradost.cloudstream3.databinding.AddSiteInputBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.ui.EasterEggMonke +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.beneneCount import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted +import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showBatteryOptimizationDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog @@ -38,16 +46,19 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import kotlinx.android.synthetic.main.add_remove_sites.* -import kotlinx.android.synthetic.main.add_site_input.* -import java.io.File +import com.lagradost.safefile.SafeFile +// Change local language settings in the app. fun getCurrentLocale(context: Context): String { val res = context.resources - // Change locale settings in the app. - // val dm = res.displayMetrics val conf = res.configuration - return conf?.locale?.toString() ?: "en" + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + conf?.locales?.get(0)?.toString() ?: "en" + } else { + @Suppress("DEPRECATION") + conf?.locale?.toString() ?: "en" + } } // idk, if you find a way of automating this it would be great @@ -56,54 +67,72 @@ fun getCurrentLocale(context: Context): String { // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto val appLanguages = arrayListOf( /* begin language list */ + Triple("", "Afrikaans", "af"), + Triple("", "عربي شامي", "ajp"), + Triple("", "አማርኛ", "am"), Triple("", "العربية", "ar"), - Triple("", "български език", "bg"), + Triple("", "اللهجة النجدية", "ars"), + Triple("", "অসমীয়া", "as"), + Triple("", "български", "bg"), Triple("", "বাংলা", "bn"), - Triple("\uD83C\uDDE7\uD83C\uDDF7", "Brazilian Portuguese", "bp"), + Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"), Triple("", "čeština", "cs"), Triple("", "Deutsch", "de"), - Triple("", "ελληνικά", "el"), + Triple("", "Ελληνικά", "el"), Triple("", "English", "en"), Triple("", "Esperanto", "eo"), - Triple("", "Español", "es"), + Triple("", "español", "es"), Triple("", "فارسی", "fa"), + Triple("", "fil", "fil"), Triple("", "français", "fr"), + Triple("", "galego", "gl"), Triple("", "हिन्दी", "hi"), - Triple("", "hrvatski jezik", "hr"), + Triple("", "hrvatski", "hr"), Triple("", "magyar", "hu"), - Triple("\uD83C\uDDEE\uD83C\uDDE9", "Indonesian", "in"), - Triple("", "Italiano", "it"), - Triple("\uD83C\uDDEE\uD83C\uDDF1", "עִברִית", "iw"), + Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), + Triple("", "italiano", "it"), + Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"), + Triple("", "日本語 (にほんご)", "ja"), Triple("", "ಕನ್ನಡ", "kn"), - Triple("", "македонски јазик", "mk"), + Triple("", "한국어", "ko"), + Triple("", "lietuvių kalba", "lt"), + Triple("", "latviešu valoda", "lv"), + Triple("", "македонски", "mk"), Triple("", "മലയാളം", "ml"), + Triple("", "bahasa Melayu", "ms"), + Triple("", "Malti", "mt"), + Triple("", "ဗမာစာ", "my"), + Triple("", "नेपाली", "ne"), Triple("", "Nederlands", "nl"), - Triple("", "Norsk nynorsk", "nn"), - Triple("", "Norsk", "no"), - Triple("", "język polski", "pl"), - Triple("\uD83C\uDDF5\uD83C\uDDF9", "Português", "pt"), - Triple("🦍", "mmmm... monke", "qt"), - Triple("", "Română", "ro"), - Triple("", "Русский", "ru"), + Triple("", "norsk nynorsk", "nn"), + Triple("", "norsk bokmål", "no"), + Triple("", "ଓଡ଼ିଆ", "or"), + Triple("", "polski", "pl"), + Triple("\uD83C\uDDF5\uD83C\uDDF9", "português", "pt"), + Triple("\uD83E\uDD8D", "mmmm... monke", "qt"), + Triple("", "română", "ro"), + Triple("", "русский", "ru"), Triple("", "slovenčina", "sk"), Triple("", "Soomaaliga", "so"), Triple("", "svenska", "sv"), Triple("", "தமிழ்", "ta"), - Triple("", "Wikang Tagalog", "tl"), + Triple("", "ትግርኛ", "ti"), + Triple("", "Tagalog", "tl"), Triple("", "Türkçe", "tr"), - Triple("", "Українська", "uk"), + Triple("", "українська", "uk"), Triple("", "اردو", "ur"), Triple("", "Tiếng Việt", "vi"), - Triple("", "中文 (Zhōngwén)", "zh"), - Triple("\uD83C\uDDF9\uD83C\uDDFC", "Chinese Traditional", "zh-rTW"), + Triple("", "中文", "zh"), + Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), /* end language list */ -).sortedBy { it.second } //ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top class SettingsGeneral : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) setPaddingBottom() + setToolBarScrollFlags() } data class CustomSite( @@ -129,8 +158,9 @@ class SettingsGeneral : PreferenceFragmentCompat() { context.contentResolver.takePersistableUriPermission(uri, flags) - val file = UniFile.fromUri(context, uri) - println("Selected URI path: $uri - Full path: ${file.filePath}") + val file = SafeFile.fromUri(context, uri) + val filePath = file?.filePath() + println("Selected URI path: $uri - Full path: $filePath") // Stores the real URI using download_path_key // Important that the URI is stored instead of filepath due to permissions. @@ -139,7 +169,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { // From URI -> File path // File path here is purely for cosmetic purposes in settings - (file.filePath ?: uri.toString()).let { + (filePath ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context) .edit().putString(getString(R.string.download_path_pref), it).apply() } @@ -147,7 +177,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() - setPreferencesFromResource(R.xml.settins_general, rootKey) + setPreferencesFromResource(R.xml.settings_general, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) fun getCurrent(): MutableList { @@ -157,9 +187,6 @@ class SettingsGeneral : PreferenceFragmentCompat() { getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> val tempLangs = appLanguages.toMutableList() - //if (beneneCount > 100) { - // tempLangs.add(Triple("\uD83E\uDD8D", "mmmm... monke", "mo")) - //} val current = getCurrentLocale(pref.context) val languageCodes = tempLangs.map { (_, _, iso) -> iso } val languageNames = tempLangs.map { (emoji, name, iso) -> @@ -183,9 +210,20 @@ class SettingsGeneral : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.battery_optimisation_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false + + if (isAppRestricted(ctx)) { + showBatteryOptimizationDialog(ctx) + } else { + showToast(R.string.app_unrestricted_toast) + } + + true + } fun showAdd() { - val providers = allProviders.distinctBy { it.javaClass }.sortedBy { it.name } + val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -194,21 +232,23 @@ class SettingsGeneral : PreferenceFragmentCompat() { {}) { selection -> val provider = providers.getOrNull(selection) ?: return@showDialog + val binding : AddSiteInputBinding = AddSiteInputBinding.inflate(layoutInflater,null,false) + val builder = AlertDialog.Builder(context ?: return@showDialog, R.style.AlertDialogCustom) - .setView(R.layout.add_site_input) + .setView(binding.root) val dialog = builder.create() dialog.show() - dialog.text2?.text = provider.name - dialog.apply_btt?.setOnClickListener { - val name = dialog.site_name_input?.text?.toString() - val url = dialog.site_url_input?.text?.toString() - val lang = dialog.site_lang_input?.text?.toString() + binding.text2.text = provider.name + binding.applyBtt.setOnClickListener { + val name = binding.siteNameInput.text?.toString() + val url = binding.siteUrlInput.text?.toString() + val lang = binding.siteLangInput.text?.toString() val realLang = if (lang.isNullOrBlank()) provider.lang else lang if (url.isNullOrBlank() || name.isNullOrBlank() || realLang.length != 2) { - showToast(activity, R.string.error_invalid_data, Toast.LENGTH_SHORT) + showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -216,10 +256,12 @@ class SettingsGeneral : PreferenceFragmentCompat() { val newSite = CustomSite(provider.javaClass.simpleName, name, url, realLang) current.add(newSite) setKey(USER_PROVIDER_API, current.toTypedArray()) + // reload apis + MainActivity.afterPluginsLoadedEvent.invoke(false) dialog.dismissSafe(activity) } - dialog.cancel_btt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } @@ -239,18 +281,19 @@ class SettingsGeneral : PreferenceFragmentCompat() { } fun showAddOrDelete() { + val binding : AddRemoveSitesBinding = AddRemoveSitesBinding.inflate(layoutInflater,null,false) val builder = AlertDialog.Builder(context ?: return, R.style.AlertDialogCustom) - .setView(R.layout.add_remove_sites) + .setView(binding.root) val dialog = builder.create() dialog.show() - dialog.add_site?.setOnClickListener { + binding.addSite.setOnClickListener { showAdd() dialog.dismissSafe(activity) } - dialog.remove_site?.setOnClickListener { + binding.removeSite.setOnClickListener { showDelete() dialog.dismissSafe(activity) } @@ -294,34 +337,38 @@ class SettingsGeneral : PreferenceFragmentCompat() { } return@setOnPreferenceClickListener true } + fun getDownloadDirs(): List { return normalSafeApiCall { - val defaultDir = VideoDownloadManager.getDownloadDir()?.filePath + context?.let { ctx -> + val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() - // app_name_download_path = Cloudstream and does not change depending on release. - // DOES NOT WORK ON SCOPED STORAGE. - val secondaryDir = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) null else Environment.getExternalStorageDirectory().absolutePath + - File.separator + resources.getString(R.string.app_name_download_path) - val first = listOf(defaultDir, secondaryDir) - (try { - val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second } + val first = listOf(defaultDir) + (try { + val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second } - (first + - requireContext().getExternalFilesDirs("").mapNotNull { it.path } + - currentDir) - } catch (e: Exception) { - first - }).filterNotNull().distinct() + (first + + ctx.getExternalFilesDirs("").mapNotNull { it.path } + + currentDir) + } catch (e: Exception) { + first + }).filterNotNull().distinct() + } } ?: emptyList() } + settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply() + getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue -> + setKey(getString(R.string.jsdelivr_proxy_key), newValue) + return@setOnPreferenceChangeListener true + } + getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = settingsManager.getString(getString(R.string.download_path_pref), null) - ?: VideoDownloadManager.getDownloadDir().toString() + ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf("Custom"), @@ -350,30 +397,30 @@ class SettingsGeneral : PreferenceFragmentCompat() { } try { - SettingsFragment.beneneCount = + beneneCount = settingsManager.getInt(getString(R.string.benene_count), 0) getPref(R.string.benene_count)?.let { pref -> pref.summary = - if (SettingsFragment.beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( + if (beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( R.string.benene_count_text ).format( - SettingsFragment.beneneCount + beneneCount ) pref.setOnPreferenceClickListener { try { - SettingsFragment.beneneCount++ - if (SettingsFragment.beneneCount%20 == 0) { + beneneCount++ + if (beneneCount%20 == 0) { val intent = Intent(context, EasterEggMonke::class.java) startActivity(intent) } settingsManager.edit().putInt( getString(R.string.benene_count), - SettingsFragment.beneneCount + beneneCount ) .apply() it.summary = - getString(R.string.benene_count_text).format(SettingsFragment.beneneCount) + getString(R.string.benene_count_text).format(beneneCount) } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 33d41934..1753032a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -7,9 +7,15 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hidePrefs import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment @@ -23,12 +29,26 @@ class SettingsPlayer : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_player) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + //Hide specific prefs on TV/EMULATOR + hidePrefs( + listOf( + R.string.pref_category_gestures_key, + R.string.rotate_video_key, + R.string.auto_rotate_video_key + ), + TV or EMULATOR + ) + + getPref(R.string.preview_seekbar_key)?.hideOn(TV) + getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) + getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_length_names) val prefValues = resources.getIntArray(R.array.video_buffer_length_values) @@ -67,10 +87,6 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - /*(getPref(R.string.double_tap_seek_time_key) as? SeekBarPreference?)?.let { - - }*/ - getPref(R.string.prefer_limit_title_rez_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.limit_title_rez_pref_names) val prefValues = resources.getIntArray(R.array.limit_title_rez_pref_values) @@ -89,8 +105,10 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.hide_player_control_names_key)?.hideOn(TV) + getPref(R.string.quality_pref_key)?.setOnPreferenceClickListener { - val prefValues = Qualities.values().map { it.value }.reversed().toMutableList() + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } @@ -98,7 +116,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_key), - Qualities.values().last().value + Qualities.entries.last().value ) activity?.showBottomDialog( @@ -113,6 +131,30 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.quality_pref_mobile_data_key)?.setOnPreferenceClickListener { + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() + prefValues.remove(Qualities.Unknown.value) + + val prefNames = prefValues.map { Qualities.getStringByInt(it) } + + val currentQuality = + settingsManager.getInt( + getString(R.string.quality_pref_mobile_data_key), + Qualities.entries.last().value + ) + + activity?.showBottomDialog( + prefNames.toList(), + prefValues.indexOf(currentQuality), + getString(R.string.watch_quality_pref_data), + true, + {}) { + settingsManager.edit().putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) + .apply() + } + return@setOnPreferenceClickListener true + } + getPref(R.string.player_pref_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.player_pref_names) val prefValues = resources.getIntArray(R.array.player_pref_values) @@ -201,6 +243,5 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index 3b01508d..cb7d25fd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -2,17 +2,19 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View +import androidx.navigation.NavOptions +import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard @@ -22,6 +24,7 @@ class SettingsProviders : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -31,7 +34,7 @@ class SettingsProviders : PreferenceFragmentCompat() { getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> - val dublist = DubStatus.values() + val dublist = DubStatus.entries val names = dublist.map { it.name } val currentList = ArrayList() @@ -56,6 +59,20 @@ class SettingsProviders : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.test_providers_key)?.setOnPreferenceClickListener { + // Somehow animations do not work without this. + val options = NavOptions.Builder() + .setEnterAnim(R.anim.enter_anim) + .setExitAnim(R.anim.exit_anim) + .setPopEnterAnim(R.anim.pop_enter) + .setPopExitAnim(R.anim.pop_exit) + .build() + + this@SettingsProviders.findNavController() + .navigate(R.id.navigation_test_providers, null, options) + true + } + getPref(R.string.prefer_media_type_key)?.setOnPreferenceClickListener { val names = enumValues().sorted().map { it.name } val default = @@ -79,7 +96,7 @@ class SettingsProviders : PreferenceFragmentCompat() { this.getString(R.string.prefer_media_type_key), selectedList.map { it.toString() }.toMutableSet() ).apply() - removeKey(USER_SELECTED_HOMEPAGE_API) + DataStoreHelper.currentHomePage = null //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } @@ -88,8 +105,10 @@ class SettingsProviders : PreferenceFragmentCompat() { getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { activity?.getApiProviderLangSettings()?.let { current -> - val languages = APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName + val languages = synchronized(APIHolder.apis) { + APIHolder.apis.map { it.lang }.toSet() + .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName + } val currentList = current.map { languages.indexOf(it) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index e2fd24ca..8c3ad0ad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -9,10 +9,11 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.search.SearchResultBuilder +import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog @@ -23,11 +24,12 @@ class SettingsUI : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() - setPreferencesFromResource(R.xml.settins_ui, rootKey) + setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { @@ -86,10 +88,9 @@ class SettingsUI : PreferenceFragmentCompat() { getPref(R.string.app_theme_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_names).toMutableList() val prefValues = resources.getStringArray(R.array.themes_names_values).toMutableList() - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + val removeIncompatible = { text: String -> val toRemove = prefValues - .mapIndexed { idx, s -> if (s.startsWith("Monet")) idx else null } + .mapIndexed { idx, s -> if (s.startsWith(text)) idx else null } .filterNotNull() var offset = 0 toRemove.forEach { idx -> @@ -98,6 +99,12 @@ class SettingsUI : PreferenceFragmentCompat() { offset += 1 } } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + removeIncompatible("Monet") + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // Remove system on android 9 and less + removeIncompatible("System") + } val currentLayout = settingsManager.getString(getString(R.string.app_theme_key), prefValues.first()) @@ -121,7 +128,8 @@ class SettingsUI : PreferenceFragmentCompat() { } getPref(R.string.primary_color_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_overlay_names).toMutableList() - val prefValues = resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() + val prefValues = + resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less val toRemove = prefValues diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index f9ac3fee..260c6674 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -1,41 +1,50 @@ package com.lagradost.cloudstream3.ui.settings -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle -import android.os.TransactionTooLargeException import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.databinding.LogcatBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.services.BackupWorkManager +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.BackupUtils.backup +import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.android.synthetic.main.logcat.* import okhttp3.internal.closeQuietly import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStream +import java.lang.System.currentTimeMillis +import java.text.SimpleDateFormat +import java.util.* class SettingsUpdates : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -44,7 +53,30 @@ class SettingsUpdates : PreferenceFragmentCompat() { //val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.backup_key)?.setOnPreferenceClickListener { - activity?.backup() + BackupUtils.backup(activity) + return@setOnPreferenceClickListener true + } + + getPref(R.string.automatic_backup_key)?.setOnPreferenceClickListener { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val prefNames = resources.getStringArray(R.array.periodic_work_names) + val prefValues = resources.getIntArray(R.array.periodic_work_values) + val current = settingsManager.getInt(getString(R.string.automatic_backup_key), 0) + + activity?.showDialog( + prefNames.toList(), + prefValues.indexOf(current), + getString(R.string.backup_frequency), + true, + {}) { index -> + settingsManager.edit() + .putInt(getString(R.string.automatic_backup_key), prefValues[index]).apply() + BackupWorkManager.enqueuePeriodicWork( + context ?: AcraApplication.context, + prefValues[index].toLong() + ) + } return@setOnPreferenceClickListener true } @@ -60,7 +92,9 @@ class SettingsUpdates : PreferenceFragmentCompat() { getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> val builder = AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) - .setView(R.layout.logcat) + + val binding = LogcatBinding.inflate(layoutInflater, null, false) + builder.setView(binding.root) val dialog = builder.create() dialog.show() @@ -81,47 +115,43 @@ class SettingsUpdates : PreferenceFragmentCompat() { } val text = log.toString() - dialog.text1?.text = text + binding.text1.text = text - dialog.copy_btt?.setOnClickListener { - // Can crash on too much text - try { - val serviceClipboard = - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?) - ?: return@setOnClickListener - val clip = ClipData.newPlainText("logcat", text) - serviceClipboard.setPrimaryClip(clip) - dialog.dismissSafe(activity) - } catch (e: TransactionTooLargeException) { - showToast(activity, R.string.clipboard_too_large) - } + binding.copyBtt.setOnClickListener { + clipboardHelper(txt("Logcat"), text) + dialog.dismissSafe(activity) } - dialog.clear_btt?.setOnClickListener { + + binding.clearBtt.setOnClickListener { Runtime.getRuntime().exec("logcat -c") dialog.dismissSafe(activity) } - dialog.save_btt?.setOnClickListener { + + binding.saveBtt.setOnClickListener { + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) var fileStream: OutputStream? = null try { - fileStream = - VideoDownloadManager.setupStream( + fileStream = VideoDownloadManager.setupStream( it.context, - "logcat", + "logcat_${date}", null, "txt", false - ).fileStream - fileStream?.writer()?.write(text) - } catch (e: Exception) { - logError(e) + ).openNew() + fileStream.writer().write(text) + dialog.dismissSafe(activity) + } catch (t: Throwable) { + logError(t) + showToast(t.message) } finally { fileStream?.closeQuietly() - dialog.dismissSafe(activity) } } - dialog.close_btt?.setOnClickListener { + + binding.closeBtt.setOnClickListener { dialog.dismissSafe(activity) } + return@setOnPreferenceClickListener true } @@ -139,10 +169,10 @@ class SettingsUpdates : PreferenceFragmentCompat() { prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {}) { + {}) { num -> try { settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), prefValues[it]) + .putInt(getString(R.string.apk_installer_key), prefValues[num]) .apply() } catch (e: Exception) { logError(e) @@ -156,7 +186,6 @@ class SettingsUpdates : PreferenceFragmentCompat() { if (activity?.runAutoUpdate(false) == false) { activity?.runOnUiThread { showToast( - activity, R.string.no_update_found, Toast.LENGTH_SHORT ) @@ -165,5 +194,27 @@ class SettingsUpdates : PreferenceFragmentCompat() { } return@setOnPreferenceClickListener true } + + getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) + + val prefNames = resources.getStringArray(R.array.auto_download_plugin) + val prefValues = + enumValues().sortedBy { x -> x.value }.map { x -> x.value } + + val current = settingsManager.getInt(getString(R.string.auto_download_plugins_key), 0) + + activity?.showBottomDialog( + prefNames.toList(), + prefValues.indexOf(current), + getString(R.string.automatic_plugin_download_mode_title), + true, + {}) { num -> + settingsManager.edit() + .putInt(getString(R.string.auto_download_plugins_key), prefValues[num]).apply() + (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + } + return@setOnPreferenceClickListener true + } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt index 7e60910d..1b487629 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -1,9 +1,9 @@ package com.lagradost.cloudstream3.ui.settings.extensions -import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.DialogInterface +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -13,34 +13,47 @@ import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.marginBottom +import androidx.core.view.marginTop import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.Some +import com.lagradost.cloudstream3.databinding.AddRepoInputBinding +import com.lagradost.cloudstream3.databinding.FragmentExtensionsBinding import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setText -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.AppUtils.downloadAllPluginsDialog -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.widget.LinearRecycleViewLayoutManager -import kotlinx.android.synthetic.main.add_repo_input.* -import kotlinx.android.synthetic.main.fragment_extensions.* class ExtensionsFragment : Fragment() { + var binding: FragmentExtensionsBinding? = null + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.fragment_extensions, container, false) + ): View { + val localBinding = FragmentExtensionsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_extensions, container, false) } private fun View.setLayoutWidth(weight: Int) { @@ -74,55 +87,89 @@ class ExtensionsFragment : Fragment() { //context?.fixPaddingStatusbar(extensions_root) setUpToolbar(R.string.extensions) + setToolBarScrollFlags() - repo_recycler_view?.adapter = RepoAdapter(false, { - findNavController().navigate( - R.id.navigation_settings_extensions_to_navigation_settings_plugins, - PluginsFragment.newInstance( - it.name, - it.url, - false - ) + binding?.repoRecyclerView?.apply { + setLinearListLayout( + isHorizontal = false, + nextUp = R.id.settings_toolbar, //FOCUS_SELF, // back has no id so we cant :pensive: + nextDown = R.id.plugin_storage_appbar, + nextRight = FOCUS_SELF, + nextLeft = R.id.nav_rail_view ) - }, { repo -> - // Prompt user before deleting repo - main { - val builder = AlertDialog.Builder(context ?: view.context) - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - ioSafe { - RepositoryManager.removeRepository(view.context, repo) - extensionViewModel.loadStats() - extensionViewModel.loadRepositories() - } - } - DialogInterface.BUTTON_NEGATIVE -> {} - } + + if (!isLayout(TV)) + binding?.addRepoButton?.let { button -> + button.post { + setPadding( + paddingLeft, + paddingTop, + paddingRight, + button.measuredHeight + button.marginTop + button.marginBottom + ) } + } - builder.setTitle(R.string.delete_repository) - .setMessage( - context?.getString(R.string.delete_repository_plugins) - ) - .setPositiveButton(R.string.delete, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + val dy = scrollY - oldScrollY + if (dy > 0) { //check for scroll down + binding?.addRepoButton?.shrink() // hide + } else if (dy < -5) { + binding?.addRepoButton?.extend() // show + } + } } - }) + adapter = RepoAdapter(false, { + findNavController().navigate( + R.id.navigation_settings_extensions_to_navigation_settings_plugins, + PluginsFragment.newInstance( + it.name, + it.url, + false + ) + ) + }, { repo -> + // Prompt user before deleting repo + main { + val builder = AlertDialog.Builder(context ?: view.context) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + ioSafe { + RepositoryManager.removeRepository(view.context, repo) + extensionViewModel.loadStats() + extensionViewModel.loadRepositories() + } + } - observe(extensionViewModel.repositories) { - repo_recycler_view?.isVisible = it.isNotEmpty() - blank_repo_screen?.isVisible = it.isEmpty() - (repo_recycler_view?.adapter as? RepoAdapter)?.updateList(it) + DialogInterface.BUTTON_NEGATIVE -> {} + } + } + + builder.setTitle(R.string.delete_repository) + .setMessage( + context?.getString(R.string.delete_repository_plugins) + ) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } + }) } - repo_recycler_view?.apply { + observe(extensionViewModel.repositories) { + binding?.repoRecyclerView?.isVisible = it.isNotEmpty() + binding?.blankRepoScreen?.isVisible = it.isEmpty() + (binding?.repoRecyclerView?.adapter as? RepoAdapter)?.updateList(it) + } + + /*binding?.repoRecyclerView?.apply { context?.let { ctx -> layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) } - } + }*/ // list_repositories?.setOnClickListener { // // Open webview on tv if browser fails @@ -139,32 +186,31 @@ class ExtensionsFragment : Fragment() { // } // } - observe(extensionViewModel.pluginStats) { - when (it) { - is Some.Success -> { - val value = it.value + observeNullable(extensionViewModel.pluginStats) { value -> + binding?.apply { + if (value == null) { + pluginStorageAppbar.isVisible = false - plugin_storage_appbar?.isVisible = true - if (value.total == 0) { - plugin_download?.setLayoutWidth(1) - plugin_disabled?.setLayoutWidth(0) - plugin_not_downloaded?.setLayoutWidth(0) - } else { - plugin_download?.setLayoutWidth(value.downloaded) - plugin_disabled?.setLayoutWidth(value.disabled) - plugin_not_downloaded?.setLayoutWidth(value.notDownloaded) - } - plugin_not_downloaded_txt.setText(value.notDownloadedText) - plugin_disabled_txt.setText(value.disabledText) - plugin_download_txt.setText(value.downloadedText) + return@observeNullable } - is Some.None -> { - plugin_storage_appbar?.isVisible = false + + pluginStorageAppbar.isVisible = true + if (value.total == 0) { + pluginDownload.setLayoutWidth(1) + pluginDisabled.setLayoutWidth(0) + pluginNotDownloaded.setLayoutWidth(0) + } else { + pluginDownload.setLayoutWidth(value.downloaded) + pluginDisabled.setLayoutWidth(value.disabled) + pluginNotDownloaded.setLayoutWidth(value.notDownloaded) } + pluginNotDownloadedTxt.setText(value.notDownloadedText) + pluginDisabledTxt.setText(value.disabledText) + pluginDownloadTxt.setText(value.downloadedText) } } - plugin_storage_appbar?.setOnClickListener { + binding?.pluginStorageAppbar?.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -176,16 +222,18 @@ class ExtensionsFragment : Fragment() { } val addRepositoryClick = View.OnClickListener { + val ctx = context ?: return@OnClickListener + val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false) val builder = - AlertDialog.Builder(context ?: return@OnClickListener, R.style.AlertDialogCustom) - .setView(R.layout.add_repo_input) + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + .setView(binding.root) val dialog = builder.create() dialog.show() (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( 0 )?.text?.toString()?.let { copy -> - dialog.repo_url_input?.setText(copy) + binding.repoUrlInput.setText(copy) } // dialog.list_repositories?.setOnClickListener { @@ -195,44 +243,62 @@ class ExtensionsFragment : Fragment() { // } // dialog.text2?.text = provider.name - dialog.apply_btt?.setOnClickListener secondListener@{ - val name = dialog.repo_name_input?.text?.toString() + binding.applyBtt.setOnClickListener secondListener@{ + val name = binding.repoNameInput.text?.toString() ioSafe { - val url = dialog.repo_url_input?.text?.toString() + val url = binding.repoUrlInput.text?.toString() ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { - showToast(activity, R.string.error_invalid_data, Toast.LENGTH_SHORT) + showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) } } else { + val repository = RepositoryManager.parseRepository(url) + + // Exit if wrong repository + if (repository == null) { + showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG) + return@ioSafe + } + val fixedName = if (!name.isNullOrBlank()) name - else RepositoryManager.parseRepository(url)?.name ?: "No name" + else repository.name val newRepo = RepositoryData(fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() - this@ExtensionsFragment.activity?.downloadAllPluginsDialog(url, fixedName) + + val plugins = RepositoryManager.getRepoPlugins(url) + if (plugins.isNullOrEmpty()) { + showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG) + } else { + this@ExtensionsFragment.activity?.addRepositoryDialog( + fixedName, + url, + ) + } } } dialog.dismissSafe(activity) } - dialog.cancel_btt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } - val isTv = isTrueTvSettings() - add_repo_button?.isGone = isTv - add_repo_button_imageview_holder?.isVisible = isTv + val isTv = isLayout(TV) + binding?.apply { + addRepoButton.isGone = isTv + addRepoButtonImageviewHolder.isVisible = isTv - // Band-aid for Fire TV - plugin_storage_appbar?.isFocusableInTouchMode = isTv - add_repo_button_imageview?.isFocusableInTouchMode = isTv - - add_repo_button?.setOnClickListener(addRepositoryClick) - add_repo_button_imageview?.setOnClickListener(addRepositoryClick) + // Band-aid for Fire TV + pluginStorageAppbar.isFocusableInTouchMode = isTv + addRepoButtonImageview.isFocusableInTouchMode = isTv + addRepoButton.setOnClickListener(addRepositoryClick) + addRepoButtonImageview.setOnClickListener(addRepositoryClick) + } reloadRepositories() } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index 63ed5357..866d167c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -7,7 +7,6 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.mvvm.Some import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginsOnline @@ -40,8 +39,8 @@ class ExtensionsViewModel : ViewModel() { private val _repositories = MutableLiveData>() val repositories: LiveData> = _repositories - private val _pluginStats: MutableLiveData> = MutableLiveData(Some.None) - val pluginStats: LiveData> = _pluginStats + private val _pluginStats: MutableLiveData = MutableLiveData(null) + val pluginStats: LiveData = _pluginStats //TODO CACHE GET REQUESTS // DO not use viewModelScope.launchSafe, it will ANR on slow internet @@ -78,7 +77,7 @@ class ExtensionsViewModel : ViewModel() { debugAssert({ stats.downloaded + stats.notDownloaded + stats.disabled != stats.total }) { "downloaded(${stats.downloaded}) + notDownloaded(${stats.notDownloaded}) + disabled(${stats.disabled}) != total(${stats.total})" } - _pluginStats.postValue(Some.Success(stats)) + _pluginStats.postValue(stats) } private fun repos() = (getKey>(REPOSITORIES_KEY) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt index 0c3d481b..d159539d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -1,10 +1,11 @@ package com.lagradost.cloudstream3.ui.settings.extensions +import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.util.Log import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible @@ -13,24 +14,27 @@ import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.ui.result.setText import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.html +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.GlideApp import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.repository_item.view.* -import org.junit.Assert -import org.junit.Test import java.text.DecimalFormat - +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow +import org.junit.Test +import org.junit.Assert data class PluginViewData( val plugin: Plugin, @@ -44,9 +48,11 @@ class PluginAdapter( private val plugins: MutableList = mutableListOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = if(isTrueTvSettings()) R.layout.repository_item_tv else R.layout.repository_item + val layout = if(isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item + val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) + return PluginViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false) + RepositoryItemBinding.bind(inflated) // may crash ) } @@ -82,8 +88,10 @@ class PluginAdapter( // Clear glide image because setImageResource doesn't override override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - holder.itemView.entry_icon?.let { pluginIcon -> - GlideApp.with(pluginIcon).clear(pluginIcon) + if (holder is PluginViewHolder) { + holder.binding.entryIcon.let { pluginIcon -> + com.bumptech.glide.Glide.with(pluginIcon).clear(pluginIcon) + } } super.onViewRecycled(holder) } @@ -95,6 +103,8 @@ class PluginAdapter( return findClosestBase2(target, current * 2, max) } + // DO NOT MOVE, as running this test will result in ExceptionInInitializerError on prerelease due to static variables using Resources.getSystem() + // this test function is only to show how the function works @Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) @@ -112,14 +122,11 @@ class PluginAdapter( fun prettyCount(number: Number): String? { val suffix = charArrayOf(' ', 'k', 'M', 'B', 'T', 'P', 'E') val numValue = number.toLong() - val value = Math.floor(Math.log10(numValue.toDouble())).toInt() + val value = floor(log10(numValue.toDouble())).toInt() val base = value / 3 return if (value >= 3 && base < suffix.size) { DecimalFormat("#0.00").format( - numValue / Math.pow( - 10.0, - (base * 3).toDouble() - ) + numValue / 10.0.pow((base * 3).toDouble()) ) + suffix[base] } else { DecimalFormat().format(numValue) @@ -127,9 +134,10 @@ class PluginAdapter( } } - inner class PluginViewHolder(itemView: View) : - RecyclerView.ViewHolder(itemView) { + inner class PluginViewHolder(val binding: RepositoryItemBinding) : + RecyclerView.ViewHolder(binding.root) { + @SuppressLint("SetTextI18n") fun bind( data: PluginViewData, ) { @@ -138,17 +146,17 @@ class PluginAdapter( val name = metadata.name.removeSuffix("Provider") val alpha = if (disabled) 0.6f else 1f val isLocal = !data.plugin.second.url.startsWith("http") - itemView.main_text?.alpha = alpha - itemView.sub_text?.alpha = alpha + binding.mainText.alpha = alpha + binding.subText.alpha = alpha val drawableInt = if (data.isDownloaded) R.drawable.ic_baseline_delete_outline_24 else R.drawable.netflix_download - itemView.nsfw_marker?.isVisible = metadata.tvTypes?.contains("NSFW") ?: false - itemView.action_button?.setImageResource(drawableInt) + binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false + binding.actionButton.setImageResource(drawableInt) - itemView.action_button?.setOnClickListener { + binding.actionButton.setOnClickListener { iconClickCallback.invoke(data.plugin) } itemView.setOnClickListener { @@ -169,10 +177,11 @@ class PluginAdapter( if (data.isDownloaded) { // On local plugins page the filepath is provided instead of url. - val plugin = PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url] + val plugin = + PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url] if (plugin?.openSettings != null) { - itemView.action_settings?.isVisible = true - itemView.action_settings.setOnClickListener { + binding.actionSettings.isVisible = true + binding.actionSettings.setOnClickListener { try { plugin.openSettings!!.invoke(itemView.context) } catch (e: Throwable) { @@ -185,13 +194,13 @@ class PluginAdapter( } } } else { - itemView.action_settings?.isVisible = false + binding.actionSettings.isVisible = false } } else { - itemView.action_settings?.isVisible = false + binding.actionSettings.isVisible = false } - if (itemView.entry_icon?.setImage(//itemView.entry_icon?.height ?: + if (!binding.entryIcon.setImage(//itemView.entry_icon?.height ?: metadata.iconUrl?.replace( "%size%", "$iconSize" @@ -201,41 +210,47 @@ class PluginAdapter( ), null, errorImageDrawable = R.drawable.ic_baseline_extension_24 - ) != true + ) ) { - itemView.entry_icon?.setImageResource(R.drawable.ic_baseline_extension_24) + binding.entryIcon.setImageResource(R.drawable.ic_baseline_extension_24) } - itemView.ext_version?.isVisible = true - itemView.ext_version?.text = "v${metadata.version}" + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" if (metadata.language.isNullOrBlank()) { - itemView.lang_icon?.isVisible = false + binding.langIcon.isVisible = false } else { - itemView.lang_icon?.isVisible = true - itemView.lang_icon.text = "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + binding.langIcon.isVisible = true + binding.langIcon.text = + "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" } - itemView.ext_votes?.isVisible = false + binding.extVotes.isVisible = false if (!isLocal) { ioSafe { metadata.getVotes().main { - itemView.ext_votes?.setText(txt(R.string.extension_rating, prettyCount(it))) - itemView.ext_votes?.isVisible = true + binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(it))) + binding.extVotes.isVisible = true } } } if (metadata.fileSize != null) { - itemView.ext_filesize?.isVisible = true - itemView.ext_filesize?.text = formatShortFileSize(itemView.context, metadata.fileSize) + binding.extFilesize.isVisible = true + binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) } else { - itemView.ext_filesize?.isVisible = false + binding.extFilesize.isVisible = false } - itemView.main_text.setText(if(disabled) txt(R.string.single_plugin_disabled, name) else txt(name)) - itemView.sub_text?.isGone = metadata.description.isNullOrBlank() - itemView.sub_text?.text = metadata.description.html() + binding.mainText.setText( + if (disabled) txt( + R.string.single_plugin_disabled, + name + ) else txt(name) + ) + binding.subText.isGone = metadata.description.isNullOrBlank() + binding.subText.text = metadata.description.html() } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt index 9729b4de..7d733be0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt @@ -2,30 +2,28 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList import android.os.Bundle -import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import android.text.format.Formatter.formatFileSize +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.fragment_plugin_details.* -import android.text.format.Formatter.formatFileSize -import android.util.Log import androidx.core.view.isVisible -import com.lagradost.cloudstream3.plugins.VotingApi -import com.lagradost.cloudstream3.plugins.VotingApi.getVoteType +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes +import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso -import kotlinx.android.synthetic.main.repository_item.view.* +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.UIHelper.toPx class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragment() { @@ -43,116 +41,126 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen } } + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + var binding: FragmentPluginDetailsBinding? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_plugin_details, container, false) - + ): View { + val localBinding = FragmentPluginDetailsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_plugin_details, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val metadata = data.plugin.second - if (plugin_icon?.setImage(//plugin_icon?.height ?: - metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" - ), - null, - errorImageDrawable = R.drawable.ic_baseline_extension_24 - ) != true - ) { - plugin_icon?.setImageResource(R.drawable.ic_baseline_extension_24) - } - plugin_name?.text = metadata.name.removeSuffix("Provider") - plugin_version?.text = metadata.version.toString() - plugin_description?.text = metadata.description ?: getString(R.string.no_data) - plugin_size?.text = if (metadata.fileSize == null) getString(R.string.no_data) else formatFileSize(context, metadata.fileSize) - plugin_author?.text = if (metadata.authors.isEmpty()) getString(R.string.no_data) else metadata.authors.joinToString(", ") - plugin_status?.text = resources.getStringArray(R.array.extension_statuses)[metadata.status] - plugin_types?.text = if ((metadata.tvTypes == null) || metadata.tvTypes.isEmpty()) getString(R.string.no_data) else metadata.tvTypes.joinToString(", ") - plugin_lang?.text = if (metadata.language == null) + binding?.apply { + if (!pluginIcon.setImage(//plugin_icon?.height ?: + metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" + ), + null, + errorImageDrawable = R.drawable.ic_baseline_extension_24 + ) + ) { + pluginIcon.setImageResource(R.drawable.ic_baseline_extension_24) + } + pluginName.text = metadata.name.removeSuffix("Provider") + pluginVersion.text = metadata.version.toString() + pluginDescription.text = metadata.description ?: getString(R.string.no_data) + pluginSize.text = + if (metadata.fileSize == null) getString(R.string.no_data) else formatFileSize( + context, + metadata.fileSize + ) + pluginAuthor.text = + if (metadata.authors.isEmpty()) getString(R.string.no_data) else metadata.authors.joinToString( + ", " + ) + pluginStatus.text = + resources.getStringArray(R.array.extension_statuses)[metadata.status] + pluginTypes.text = + if (metadata.tvTypes.isNullOrEmpty()) getString(R.string.no_data) else metadata.tvTypes.joinToString( + ", " + ) + pluginLang.text = if (metadata.language == null) getString(R.string.no_data) - else + else "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" - github_btn.setOnClickListener { - if (metadata.repositoryUrl != null) { - openBrowser(metadata.repositoryUrl) + githubBtn.setOnClickListener { + if (metadata.repositoryUrl != null) { + openBrowser(metadata.repositoryUrl) + } } - } - if (!metadata.canVote()) { - downvote.alpha = .6f - upvote.alpha = .6f - } + if (!metadata.canVote()) { + upvote.alpha = .6f + } - if (data.isDownloaded) { - // On local plugins page the filepath is provided instead of url. - val plugin = PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url] - if (plugin?.openSettings != null && context != null) { - action_settings?.isVisible = true - action_settings.setOnClickListener { - try { - plugin.openSettings!!.invoke(requireContext()) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open ${metadata.name} settings: ${ - Log.getStackTraceString(e) - }" - ) + if (data.isDownloaded) { + // On local plugins page the filepath is provided instead of url. + val plugin = + PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url] + if (plugin?.openSettings != null && context != null) { + actionSettings.isVisible = true + actionSettings.setOnClickListener { + try { + plugin.openSettings!!.invoke(requireContext()) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open ${metadata.name} settings: ${ + Log.getStackTraceString(e) + }" + ) + } } + } else { + actionSettings.isVisible = false } } else { - action_settings?.isVisible = false + actionSettings.isVisible = false } - } else { - action_settings?.isVisible = false - } - upvote.setOnClickListener { - ioSafe { - metadata.vote(VotingApi.VoteType.UPVOTE).main { - updateVoting(it) + upvote.setOnClickListener { + ioSafe { + metadata.vote().main { + updateVoting(it) + } } } - } - downvote.setOnClickListener { + ioSafe { - metadata.vote(VotingApi.VoteType.DOWNVOTE).main { + metadata.getVotes().main { updateVoting(it) } - - } - } - - ioSafe { - metadata.getVotes().main { - updateVoting(it) } } } private fun updateVoting(value: Int) { val metadata = data.plugin.second - plugin_votes.text = value.toString() - when (metadata.getVoteType()) { - VotingApi.VoteType.UPVOTE -> { - upvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.colorPrimary) ?: R.color.colorPrimary) - downvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) - } - VotingApi.VoteType.DOWNVOTE -> { - downvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.colorPrimary) ?: R.color.colorPrimary) - upvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) - } - VotingApi.VoteType.NONE -> { - upvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) - downvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) + binding?.apply { + pluginVotes.text = value.toString() + if (metadata.hasVoted()) { + upvote.imageTintList = ColorStateList.valueOf( + context?.colorFromAttribute(R.attr.colorPrimary) ?: R.color.colorPrimary + ) + } else { + upvote.imageTintList = ColorStateList.valueOf( + context?.colorFromAttribute(R.attr.colorOnSurface) ?: R.color.white + ) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index d328d226..4878049b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -8,21 +8,25 @@ import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.appLanguages +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.fragment_plugins.* -import kotlinx.android.synthetic.main.tvtypes_chips.* -import kotlinx.android.synthetic.main.tvtypes_chips_scroll.* const val PLUGINS_BUNDLE_NAME = "name" const val PLUGINS_BUNDLE_URL = "url" @@ -33,11 +37,19 @@ class PluginsFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.fragment_plugins, container, false) + ): View { + val localBinding = FragmentPluginsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_plugins, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() } private val pluginViewModel: PluginsViewModel by activityViewModels() + var binding: FragmentPluginsBinding? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -59,111 +71,134 @@ class PluginsFragment : Fragment() { val name = arguments?.getString(PLUGINS_BUNDLE_NAME) val url = arguments?.getString(PLUGINS_BUNDLE_URL) val isLocal = arguments?.getBoolean(PLUGINS_BUNDLE_LOCAL) == true + // download all extensions button + val downloadAllButton = binding?.settingsToolbar?.menu?.findItem(R.id.download_all) if (url == null || name == null) { - activity?.onBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() return } + setToolBarScrollFlags() setUpToolbar(name) - - settings_toolbar?.setOnMenuItemClickListener { menuItem -> - when (menuItem?.itemId) { - R.id.download_all -> { - PluginsViewModel.downloadAll(activity, url, pluginViewModel) - } - R.id.lang_filter -> { - val tempLangs = appLanguages.toMutableList() - val languageCodes = mutableListOf("none") + tempLangs.map { (_, _, iso) -> iso } - val languageNames = - mutableListOf(getString(R.string.no_data)) + tempLangs.map { (emoji, name, iso) -> - val flag = - emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } - "$flag $name" - } - val selectedList = - pluginViewModel.languages.map { it -> languageCodes.indexOf(it) } - - activity?.showMultiDialog( - languageNames, - selectedList, - getString(R.string.provider_lang_settings), - {}) { newList -> - pluginViewModel.languages = newList.map { it -> languageCodes[it] } - pluginViewModel.updateFilteredPlugins() + binding?.settingsToolbar?.apply { + setOnMenuItemClickListener { menuItem -> + when (menuItem?.itemId) { + R.id.download_all -> { + PluginsViewModel.downloadAll(activity, url, pluginViewModel) } + + R.id.lang_filter -> { + val tempLangs = appLanguages.toMutableList() + val languageCodes = + mutableListOf("none") + tempLangs.map { (_, _, iso) -> iso } + val languageNames = + mutableListOf(getString(R.string.no_data)) + tempLangs.map { (emoji, name, iso) -> + val flag = + emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" + } + val selectedList = + pluginViewModel.languages.map { languageCodes.indexOf(it) } + + activity?.showMultiDialog( + languageNames, + selectedList, + getString(R.string.provider_lang_settings), + {}) { newList -> + pluginViewModel.languages = newList.map { languageCodes[it] } + pluginViewModel.updateFilteredPlugins() + } + } + + else -> {} } - else -> {} + return@setOnMenuItemClickListener true } - return@setOnMenuItemClickListener true - } - val searchView = - settings_toolbar?.menu?.findItem(R.id.search_button)?.actionView as? SearchView + val searchView = + menu?.findItem(R.id.search_button)?.actionView as? SearchView - // Don't go back if active query - settings_toolbar?.setNavigationOnClickListener { - if (searchView?.isIconified == false) { - searchView.isIconified = true - } else { - activity?.onBackPressed() + // Don't go back if active query + setNavigationOnClickListener { + if (searchView?.isIconified == false) { + searchView.isIconified = true + } else { + activity?.onBackPressedDispatcher?.onBackPressed() + } + } + searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) pluginViewModel.search(null) } - } + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + pluginViewModel.search(query) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + pluginViewModel.search(newText) + return true + } + }) + } // searchView?.onActionViewCollapsed = { // pluginViewModel.search(null) // } // Because onActionViewCollapsed doesn't wanna work we need this workaround :( - searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) pluginViewModel.search(null) - } - searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - pluginViewModel.search(query) - return true - } + binding?.pluginRecyclerView?.setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) - override fun onQueryTextChange(newText: String?): Boolean { - pluginViewModel.search(newText) - return true - } - }) - - - plugin_recycler_view?.adapter = + binding?.pluginRecyclerView?.adapter = PluginAdapter { pluginViewModel.handlePluginAction(activity, url, it, isLocal) } - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - plugin_recycler_view?.setPadding(0, 0, 0, 200.toPx) + binding?.pluginRecyclerView?.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (plugin_recycler_view?.adapter as? PluginAdapter)?.updateList(list) + (binding?.pluginRecyclerView?.adapter as? PluginAdapter)?.updateList(list) if (scrollToTop) - plugin_recycler_view?.scrollToPosition(0) + binding?.pluginRecyclerView?.scrollToPosition(0) } if (isLocal) { // No download button and no categories on local - settings_toolbar?.menu?.findItem(R.id.download_all)?.isVisible = false - settings_toolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false + downloadAllButton?.isVisible = false + binding?.settingsToolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() - tv_types_scroll_view?.isVisible = false + + binding?.tvtypesChipsScroll?.root?.isVisible = false } else { pluginViewModel.updatePluginList(context, url) - tv_types_scroll_view?.isVisible = true + binding?.tvtypesChipsScroll?.root?.isVisible = true + // not needed for users but may be useful for devs + downloadAllButton?.isVisible = BuildConfig.DEBUG - bindChips(home_select_group, emptyList(), TvType.values().toList()) { list -> - pluginViewModel.tvTypes.clear() - pluginViewModel.tvTypes.addAll(list.map { it.name }) - pluginViewModel.updateFilteredPlugins() - } + + + bindChips( + binding?.tvtypesChipsScroll?.tvtypesChips, + emptyList(), + TvType.entries.toList(), + callback = { list -> + pluginViewModel.tvTypes.clear() + pluginViewModel.tvTypes.addAll(list.map { it.name }) + pluginViewModel.updateFilteredPlugins() + }, + nextFocusDown = R.id.plugin_recycler_view, + nextFocusUp = null, + ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index 934f65bb..fd5422b2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -8,9 +8,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.plugins.PluginManager @@ -86,14 +88,18 @@ class PluginsViewModel : ViewModel() { }.also { list -> main { showToast( - activity, - if (list.isEmpty()) { - txt( + when { + // No plugins at all + plugins.isEmpty() -> txt( + R.string.no_plugins_found_error, + ) + // All plugins downloaded + list.isEmpty() -> txt( R.string.batch_download_nothing_to_download_format, txt(R.string.plugin) ) - } else { - txt( + + else -> txt( R.string.batch_download_start_format, list.size, txt(if (list.size == 1) R.string.plugin_singular else R.string.plugin) @@ -113,7 +119,6 @@ class PluginsViewModel : ViewModel() { }.main { list -> if (list.any { it }) { showToast( - activity, txt( R.string.batch_download_finish_format, list.count { it }, @@ -123,7 +128,7 @@ class PluginsViewModel : ViewModel() { ) viewModel?.updatePluginListPrivate(activity, repositoryUrl) } else if (list.isNotEmpty()) { - showToast(activity, R.string.download_failed, Toast.LENGTH_SHORT) + showToast(R.string.download_failed, Toast.LENGTH_SHORT) } } } @@ -158,7 +163,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.name, + metadata.internalName, repo, isEnabled ) to message @@ -166,9 +171,9 @@ class PluginsViewModel : ViewModel() { runOnMainThread { if (success) - showToast(activity, message, Toast.LENGTH_SHORT) + showToast(message, Toast.LENGTH_SHORT) else - showToast(activity, R.string.error, Toast.LENGTH_SHORT) + showToast(R.string.error, Toast.LENGTH_SHORT) } if (success) @@ -179,8 +184,15 @@ class PluginsViewModel : ViewModel() { } private suspend fun updatePluginListPrivate(context: Context, repositoryUrl: String) { + val isAdult = PreferenceManager.getDefaultSharedPreferences(context) + .getStringSet(context.getString(R.string.prefer_media_type_key), emptySet()) + ?.contains(TvType.NSFW.ordinal.toString()) == true + val plugins = getPlugins(repositoryUrl) - val list = plugins.map { plugin -> + val list = plugins.filter { + // Show all non-nsfw plugins or all if nsfw is enabled + it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult + }.map { plugin -> PluginViewData(plugin, isDownloaded(context, plugin.second.internalName, plugin.first)) } @@ -195,7 +207,7 @@ class PluginsViewModel : ViewModel() { if (tvTypes.isEmpty()) return this return this.filter { (it.plugin.second.tvTypes?.any { type -> tvTypes.contains(type) } == true) || - (tvTypes.contains("Others") && (it.plugin.second.tvTypes + (tvTypes.contains(TvType.Others.name) && (it.plugin.second.tvTypes ?: emptyList()).isEmpty()) } } @@ -255,4 +267,4 @@ class PluginsViewModel : ViewModel() { false to downloadedPlugins.filterTvTypes().filterLang().sortByQuery(currentQuery) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt index e90166a8..faf6d38b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt @@ -1,14 +1,18 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.RepositoryItemBinding +import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import kotlinx.android.synthetic.main.repository_item.view.* +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper class RepoAdapter( val isSetup: Boolean, @@ -20,9 +24,17 @@ class RepoAdapter( private val repositories: MutableList = mutableListOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = if(isTrueTvSettings()) R.layout.repository_item_tv else R.layout.repository_item + val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) else RepositoryItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) //R.layout.repository_item_tv else R.layout.repository_item return RepoViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false) + layout ) } @@ -57,30 +69,64 @@ class RepoAdapter( diffResult.dispatchUpdatesTo(this) } - inner class RepoViewHolder(itemView: View) : - RecyclerView.ViewHolder(itemView) { + inner class RepoViewHolder( + val binding: ViewBinding + ) : + RecyclerView.ViewHolder(binding.root) { fun bind( repositoryData: RepositoryData ) { val isPrebuilt = PREBUILT_REPOSITORIES.contains(repositoryData) val drawable = if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 + when (binding) { + is RepositoryItemTvBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) + } - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - itemView.action_button?.setImageResource(drawable) - } + actionButton.setOnClickListener { + imageClickCallback(repositoryData) + } - itemView.action_button?.setOnClickListener { - imageClickCallback(repositoryData) - } + repositoryItemRoot.setOnClickListener { + clickCallback(repositoryData) + } + mainText.text = repositoryData.name + subText.text = repositoryData.url + } + } - itemView.repository_item_root?.setOnClickListener { - clickCallback(repositoryData) + is RepositoryItemBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) + } + + actionButton.setOnClickListener { + imageClickCallback(repositoryData) + } + + repositoryItemRoot.setOnClickListener { + clickCallback(repositoryData) + } + + repositoryItemRoot.setOnLongClickListener { + val shareableRepoData = "${repositoryData.name} : \n ${repositoryData.url}" + clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) + true + } + + mainText.text = repositoryData.name + subText.text = repositoryData.url + } + } } - itemView.main_text?.text = repositoryData.name - itemView.sub_text?.text = repositoryData.url } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt new file mode 100644 index 00000000..7878afaa --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt @@ -0,0 +1,108 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentTestingBinding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar + + +class TestFragment : Fragment() { + + private val testViewModel: TestViewModel by activityViewModels() + var binding: FragmentTestingBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + setUpToolbar(R.string.category_provider_test) + setToolBarScrollFlags() + super.onViewCreated(view, savedInstanceState) + + binding?.apply { + providerTestRecyclerView.adapter = TestResultAdapter( + mutableListOf() + ) + + testViewModel.init() + if (testViewModel.isRunningTest) { + providerTest.setState(TestView.TestState.Running) + } + + observe(testViewModel.providerProgress) { (passed, failed, total) -> + providerTest.setProgress(passed, failed, total) + } + + observeNullable(testViewModel.providerResults) { + normalSafeApiCall { + val newItems = it.sortedBy { api -> api.first.name } + (providerTestRecyclerView.adapter as? TestResultAdapter)?.updateList( + newItems + ) + } + } + + providerTest.setOnPlayButtonListener { state -> + when (state) { + TestView.TestState.Stopped -> testViewModel.stopTest() + TestView.TestState.Running -> testViewModel.startTest() + TestView.TestState.None -> testViewModel.startTest() + } + } + + if (isLayout(TV)) { + providerTest.playPauseButton?.isFocusableInTouchMode = true + providerTest.playPauseButton?.requestFocus() + } + + providerTest.playPauseButton?.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + providerTestAppbar.setExpanded(true, true) + } + } + + fun focusRecyclerView() { + // Hack to make it possible to focus the recyclerview. + if (isLayout(TV)) { + providerTestRecyclerView.requestFocus() + providerTestAppbar.setExpanded(false, true) + } + } + + providerTest.setOnMainClick { + testViewModel.setFilterMethod(TestViewModel.ProviderFilter.All) + focusRecyclerView() + } + providerTest.setOnFailedClick { + testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Failed) + focusRecyclerView() + } + providerTest.setOnPassedClick { + testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Passed) + focusRecyclerView() + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentTestingBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_testing, container, false) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt new file mode 100644 index 00000000..bad58a0e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt @@ -0,0 +1,117 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import android.app.AlertDialog +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding +import com.lagradost.cloudstream3.mvvm.getAllMessages +import com.lagradost.cloudstream3.mvvm.getStackTracePretty +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.TestingUtils +import java.io.File + +class TestResultAdapter(override val items: MutableList>) : + AppContextUtils.DiffAdapter>(items) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ProviderTestViewHolder( + ProviderTestItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + //LayoutInflater.from(parent.context) + // .inflate(R.layout.provider_test_item, parent, false), + ) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ProviderTestViewHolder -> { + val item = items[position] + holder.bind(item.first, item.second) + } + } + } + + inner class ProviderTestViewHolder(binding: ProviderTestItemBinding) : + RecyclerView.ViewHolder(binding.root) { + private val languageText: TextView = binding.langIcon + private val providerTitle: TextView = binding.mainText + private val statusText: TextView = binding.passedFailedMarker + private val failDescription: TextView = binding.failDescription + private val logButton: ImageView = binding.actionButton + + private fun String.lastLine(): String? { + return this.lines().lastOrNull { it.isNotBlank() } + } + + fun bind(api: MainAPI, result: TestingUtils.TestResultProvider) { + languageText.text = getFlagFromIso(api.lang) + providerTitle.text = api.name + + val (resultText, resultColor) = if (result.success) { + if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) { + R.string.test_warning to R.color.colorTestWarning + } else { + R.string.test_passed to R.color.colorTestPass + } + } else { + R.string.test_failed to R.color.colorTestFail + } + + statusText.setText(resultText) + statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) + + val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } + val messages = result.exception?.getAllMessages()?.ifBlank { null } + val resultLog = result.log.joinToString("\n") + val fullLog = + resultLog + + (messages?.let { "\n\nError: $it" } ?: "") + + (stackTrace?.let { "\n\n$it" } ?: "") + + failDescription.text = messages?.lastLine() ?: resultLog.lastLine() + + logButton.setOnClickListener { + val builder: AlertDialog.Builder = + AlertDialog.Builder(it.context, R.style.AlertDialogCustom) + builder.setMessage(fullLog) + .setTitle(R.string.test_log) + // Ok button just closes the dialog + .setPositiveButton(R.string.ok) { _, _ -> } + + api.sourcePlugin?.let { path -> + val pluginFile = File(path) + // Cannot delete a deleted plugin + if (!pluginFile.exists()) return@let + + builder.setNegativeButton(R.string.delete_plugin) { _, _ -> + ioSafe { + val success = PluginManager.deletePlugin(pluginFile) + + runOnMainThread { + if (success) { + showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) + } else { + showToast(R.string.error, Toast.LENGTH_SHORT) + } + } + } + } + } + + builder.show() + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt new file mode 100644 index 00000000..eea495a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt @@ -0,0 +1,119 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.cardview.widget.CardView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.core.widget.ContentLoadingProgressBar +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.AppContextUtils.animateProgressTo + +class TestView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : CardView(context, attrs) { + enum class TestState(@StringRes val stringRes: Int, @DrawableRes val icon: Int) { + None(R.string.start, R.drawable.ic_baseline_play_arrow_24), + + // Paused(R.string.resume, R.drawable.ic_baseline_play_arrow_24), + Stopped(R.string.restart, R.drawable.ic_baseline_play_arrow_24), + Running(R.string.stop, R.drawable.pause_to_play), + } + + var mainSection: View? = null + var testsPassedSection: View? = null + var testsFailedSection: View? = null + + var mainSectionText: TextView? = null + var mainSectionHeader: TextView? = null + var testsPassedSectionText: TextView? = null + var testsFailedSectionText: TextView? = null + var totalProgressBar: ContentLoadingProgressBar? = null + + var playPauseButton: MaterialButton? = null + var stateListener: (TestState) -> Unit = {} + + private var state = TestState.None + + init { + LayoutInflater.from(context).inflate(R.layout.view_test, this, true) + + mainSection = findViewById(R.id.main_test_section) + testsPassedSection = findViewById(R.id.passed_test_section) + testsFailedSection = findViewById(R.id.failed_test_section) + + mainSectionHeader = findViewById(R.id.main_test_header) + mainSectionText = findViewById(R.id.main_test_section_progress) + testsPassedSectionText = findViewById(R.id.passed_test_section_progress) + testsFailedSectionText = findViewById(R.id.failed_test_section_progress) + + totalProgressBar = findViewById(R.id.test_total_progress) + playPauseButton = findViewById(R.id.tests_play_pause) + + attrs?.let { + val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView) + val headerText = typedArray.getString(R.styleable.TestView_header_text) + mainSectionHeader?.text = headerText + typedArray.recycle() + } + + playPauseButton?.setOnClickListener { + val newState = when (state) { + TestState.None -> TestState.Running + TestState.Running -> TestState.Stopped + TestState.Stopped -> TestState.Running + } + setState(newState) + } + } + + fun setOnPlayButtonListener(listener: (TestState) -> Unit) { + stateListener = listener + } + + fun setState(newState: TestState) { + state = newState + stateListener.invoke(newState) + playPauseButton?.setText(newState.stringRes) + playPauseButton?.icon = ContextCompat.getDrawable(context, newState.icon) + } + + fun setProgress(passed: Int, failed: Int, total: Int?) { + val totalProgress = passed + failed + mainSectionText?.text = "$totalProgress / ${total?.toString() ?: "?"}" + testsPassedSectionText?.text = passed.toString() + testsFailedSectionText?.text = failed.toString() + + totalProgressBar?.max = (total ?: 0) * 1000 + totalProgressBar?.animateProgressTo(totalProgress * 1000) + + totalProgressBar?.isVisible = !(totalProgress == 0 || (total ?: 0) == 0) + if (totalProgress == total) { + setState(TestState.Stopped) + } + } + + fun setMainHeader(@StringRes header: Int) { + mainSectionHeader?.setText(header) + } + + fun setOnMainClick(listener: OnClickListener) { + mainSection?.setOnClickListener(listener) + } + + fun setOnPassedClick(listener: OnClickListener) { + testsPassedSection?.setOnClickListener(listener) + } + + fun setOnFailedClick(listener: OnClickListener) { + testsFailedSection?.setOnClickListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt new file mode 100644 index 00000000..818f1fd7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt @@ -0,0 +1,107 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.TestingUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel + +class TestViewModel : ViewModel() { + data class TestProgress( + val passed: Int, + val failed: Int, + val total: Int + ) + + enum class ProviderFilter { + All, + Passed, + Failed + } + + private val _providerProgress = MutableLiveData(null) + val providerProgress: LiveData = _providerProgress + + private val _providerResults = + MutableLiveData>>( + emptyList() + ) + + val providerResults: LiveData>> = + _providerResults + + private var scope: CoroutineScope? = null + val isRunningTest + get() = scope != null + + private var filter = ProviderFilter.All + private val providers = threadSafeListOf>() + private var passed = 0 + private var failed = 0 + private var total = 0 + + private fun updateProgress() { + _providerProgress.postValue(TestProgress(passed, failed, total)) + postProviders() + } + + private fun postProviders() { + synchronized(providers) { + val filtered = when (filter) { + ProviderFilter.All -> providers + ProviderFilter.Passed -> providers.filter { it.second.success } + ProviderFilter.Failed -> providers.filter { !it.second.success } + } + _providerResults.postValue(filtered) + } + } + + fun setFilterMethod(filter: ProviderFilter) { + if (this.filter == filter) return + this.filter = filter + postProviders() + } + + private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { + synchronized(providers) { + val index = providers.indexOfFirst { it.first == api } + if (index == -1) { + providers.add(api to results) + if (results.success) passed++ else failed++ + } else { + providers[index] = api to results + } + updateProgress() + } + } + + fun init() { + total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } + updateProgress() + } + + fun startTest() { + scope = CoroutineScope(Dispatchers.Default) + + val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } + total = apis.size + failed = 0 + passed = 0 + providers.clear() + updateProgress() + + TestingUtils.getDeferredProviderTests(scope ?: return, apis) { api, result -> + addProvider(api, result) + } + } + + fun stopTest() { + scope?.cancel() + scope = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt index b7d2fff6..4369b22f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt @@ -8,21 +8,16 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSetupExtensionsBinding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_extensions.blank_repo_screen -import kotlinx.android.synthetic.main.fragment_extensions.repo_recycler_view -import kotlinx.android.synthetic.main.fragment_setup_media.next_btt -import kotlinx.android.synthetic.main.fragment_setup_media.prev_btt -import kotlinx.android.synthetic.main.fragment_setup_media.setup_root class SetupFragmentExtensions : Fragment() { @@ -39,13 +34,24 @@ class SetupFragmentExtensions : Fragment() { } } + var binding: FragmentSetupExtensionsBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_setup_extensions, container, false) + ): View { + val localBinding = FragmentSetupExtensionsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_extensions, container, false) } + override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories @@ -60,12 +66,12 @@ class SetupFragmentExtensions : Fragment() { main { val repositories = RepositoryManager.getRepositories() + PREBUILT_REPOSITORIES val hasRepos = repositories.isNotEmpty() - repo_recycler_view?.isVisible = hasRepos - blank_repo_screen?.isVisible = !hasRepos + binding?.repoRecyclerView?.isVisible = hasRepos + binding?.blankRepoScreen?.isVisible = !hasRepos // view_public_repositories_button?.isVisible = hasRepos if (hasRepos) { - repo_recycler_view?.adapter = RepoAdapter(true, {}, { + binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) }).apply { updateList(repositories) } } @@ -80,39 +86,40 @@ class SetupFragmentExtensions : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + fixPaddingStatusbar(binding?.setupRoot) val isSetup = arguments?.getBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP) ?: false // view_public_repositories_button?.setOnClickListener { // openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) // } - with(context) { - if (this == null) return + normalSafeApiCall { + // val ctx = context ?: return@normalSafeApiCall setRepositories() + binding?.apply { + if (!isSetup) { + nextBtt.setText(R.string.setup_done) + } + prevBtt.isVisible = isSetup - if (!isSetup) { - next_btt.setText(R.string.setup_done) - } - prev_btt?.isVisible = isSetup + nextBtt.setOnClickListener { + // Continue setup + if (isSetup) + if ( + // If any available languages + synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } + ) { + findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) + } else { + findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_media) + } + else + findNavController().navigate(R.id.navigation_home) + } - next_btt?.setOnClickListener { - // Continue setup - if (isSetup) - if ( - // If any available languages - apis.distinctBy { it.lang }.size > 1 - ) { - findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) - } else { - findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_media) - } - else - findNavController().navigate(R.id.navigation_home) - } - - prev_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_language) + prevBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_language) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index 80db59ee..5c473b73 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -13,40 +13,49 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_language.* -import kotlinx.android.synthetic.main.fragment_setup_media.listview1 -import kotlinx.android.synthetic.main.fragment_setup_media.next_btt const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" class SetupFragmentLanguage : Fragment() { + var binding: FragmentSetupLanguageBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_setup_language, container, false) + ): View { + val localBinding = FragmentSetupLanguageBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_language, container, false) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) // We don't want a crash for all users normalSafeApiCall { - with(context) { - if (this == null) return@normalSafeApiCall - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + fixPaddingStatusbar(binding?.setupRoot) - val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) + val ctx = context ?: return@normalSafeApiCall + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val arrayAdapter = + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + + binding?.apply { // Icons may crash on some weird android versions? normalSafeApiCall { val drawable = when { @@ -54,10 +63,10 @@ class SetupFragmentLanguage : Fragment() { BuildConfig.BUILD_TYPE == "prerelease" -> R.drawable.cloud_2_gradient_beta else -> R.drawable.cloud_2_gradient } - app_icon_image?.setImageDrawable(ContextCompat.getDrawable(this, drawable)) + appIconImage.setImageDrawable(ContextCompat.getDrawable(ctx, drawable)) } - val current = getCurrentLocale(this) + val current = getCurrentLocale(ctx) val languageCodes = appLanguages.map { it.third } val languageNames = appLanguages.map { (emoji, name, iso) -> val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } @@ -66,18 +75,19 @@ class SetupFragmentLanguage : Fragment() { val index = languageCodes.indexOf(current) arrayAdapter.addAll(languageNames) - listview1?.adapter = arrayAdapter - listview1?.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1?.setItemChecked(index, true) + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE + listview1.setItemChecked(index, true) - listview1?.setOnItemClickListener { _, _, position, _ -> + listview1.setOnItemClickListener { _, _, position, _ -> val code = languageCodes[position] CommonActivity.setLocale(activity, code) - settingsManager.edit().putString(getString(R.string.locale_key), code).apply() + settingsManager.edit().putString(getString(R.string.locale_key), code) + .apply() activity?.recreate() } - next_btt?.setOnClickListener { + nextBtt.setOnClickListener { // If no plugins go to plugins page val nextDestination = if ( PluginManager.getPluginsOnline().isEmpty() @@ -92,10 +102,11 @@ class SetupFragmentLanguage : Fragment() { ) } - skip_btt?.setOnClickListener { + skipBtt.setOnClickListener { findNavController().navigate(R.id.navigation_home) } } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt index 50fb37d6..d8fa46e6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -9,31 +9,41 @@ import android.widget.ArrayAdapter import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_layout.* -import kotlinx.android.synthetic.main.fragment_setup_media.listview1 -import kotlinx.android.synthetic.main.fragment_setup_media.next_btt -import kotlinx.android.synthetic.main.fragment_setup_media.prev_btt -import kotlinx.android.synthetic.main.fragment_setup_media.setup_root import org.acra.ACRA class SetupFragmentLayout : Fragment() { + + var binding: FragmentSetupLayoutBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_setup_layout, container, false) + ): View { + val localBinding = FragmentSetupLayoutBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_layout, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + fixPaddingStatusbar(binding?.setupRoot) - with(context) { - if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + normalSafeApiCall { + val ctx = context ?: return@normalSafeApiCall + + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) @@ -42,48 +52,49 @@ class SetupFragmentLayout : Fragment() { settingsManager.getInt(getString(R.string.app_layout_key), -1) val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) - listview1?.adapter = arrayAdapter - listview1?.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1?.setItemChecked( - prefValues.indexOf(currentLayout), true - ) - - listview1?.setOnItemClickListener { _, _, position, _ -> - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[position]) - .apply() - activity?.recreate() - } - - acra_switch?.setOnCheckedChangeListener { _, enableCrashReporting -> - // Use same pref as in settings - settingsManager.edit().putBoolean(ACRA.PREF_DISABLE_ACRA, !enableCrashReporting) - .apply() - val text = - if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on - crash_reporting_text?.text = getText(text) - } - - val enableCrashReporting = !settingsManager.getBoolean(ACRA.PREF_DISABLE_ACRA, true) - acra_switch.isChecked = enableCrashReporting - crash_reporting_text.text = - getText( - if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + binding?.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE + listview1.setItemChecked( + prefValues.indexOf(currentLayout), true ) + listview1.setOnItemClickListener { _, _, position, _ -> + settingsManager.edit() + .putInt(getString(R.string.app_layout_key), prefValues[position]) + .apply() + activity?.recreate() + } + acraSwitch.setOnCheckedChangeListener { _, enableCrashReporting -> + // Use same pref as in settings + settingsManager.edit().putBoolean(ACRA.PREF_DISABLE_ACRA, !enableCrashReporting) + .apply() + val text = + if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + crashReportingText.text = getText(text) + } - next_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_home) - } + val enableCrashReporting = !settingsManager.getBoolean(ACRA.PREF_DISABLE_ACRA, true) - prev_btt?.setOnClickListener { - findNavController().popBackStack() + acraSwitch.isChecked = enableCrashReporting + crashReportingText.text = + getText( + if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + ) + + + nextBtt.setOnClickListener { + setKey(HAS_DONE_SETUP_KEY, true) + findNavController().navigate(R.id.navigation_home) + } + + prevBtt.setOnClickListener { + findNavController().popBackStack() + } } } } - - } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 257ce5c1..49a93608 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -12,70 +12,82 @@ import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_media.* class SetupFragmentMedia : Fragment() { + var binding: FragmentSetupMediaBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_setup_media, container, false) + ): View { + val localBinding = FragmentSetupMediaBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_media, container, false) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + normalSafeApiCall { + fixPaddingStatusbar(binding?.setupRoot) - with(context) { - if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val ctx = context ?: return@normalSafeApiCall + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) val names = enumValues().sorted().map { it.name } val selected = mutableListOf() arrayAdapter.addAll(names) - listview1?.let { - it.adapter = arrayAdapter - it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + binding?.apply { + listview1.let { + it.adapter = arrayAdapter + it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - it.setOnItemClickListener { _, _, _, _ -> - it.checkedItemPositions?.forEach { key, value -> - if (value) { - selected.add(key) - } else { - selected.remove(key) + it.setOnItemClickListener { _, _, _, _ -> + it.checkedItemPositions?.forEach { key, value -> + if (value) { + selected.add(key) + } else { + selected.remove(key) + } } + val prefValues = selected.mapNotNull { pos -> + val item = + it.getItemAtPosition(pos)?.toString() ?: return@mapNotNull null + val itemVal = TvType.valueOf(item) + itemVal.ordinal.toString() + }.toSet() + settingsManager.edit() + .putStringSet(getString(R.string.prefer_media_type_key), prefValues) + .apply() + + // Regenerate set homepage + DataStoreHelper.currentHomePage = null } - val prefValues = selected.mapNotNull { pos -> - val item = it.getItemAtPosition(pos)?.toString() ?: return@mapNotNull null - val itemVal = TvType.valueOf(item) - itemVal.ordinal.toString() - }.toSet() - settingsManager.edit() - .putStringSet(getString(R.string.prefer_media_type_key), prefValues) - .apply() - - // Regenerate set homepage - removeKey(USER_SELECTED_HOMEPAGE_API) } - } - next_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) - } + nextBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) + } - prev_btt?.setOnClickListener { - findNavController().popBackStack() + prevBtt.setOnClickListener { + findNavController().popBackStack() + } } } } - - } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index 51abee90..c12e9eb8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -11,36 +11,48 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_media.* class SetupFragmentProviderLanguage : Fragment() { + var binding: FragmentSetupProviderLanguagesBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_setup_provider_languages, container, false) + ): View { + val localBinding = FragmentSetupProviderLanguagesBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_provider_languages, container, false) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + fixPaddingStatusbar(binding?.setupRoot) - with(context) { - if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + normalSafeApiCall { + val ctx = context ?: return@normalSafeApiCall + + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - val current = this.getApiProviderLangSettings() - val langs = APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName + val current = ctx.getApiProviderLangSettings() + val langs = synchronized(APIHolder.apis) { APIHolder.apis.map { it.lang }.toSet() + .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName} val currentList = current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO @@ -56,31 +68,31 @@ class SetupFragmentProviderLanguage : Fragment() { } arrayAdapter.addAll(languageNames) - - listview1?.adapter = arrayAdapter - listview1?.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + binding?.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE currentList.forEach { listview1.setItemChecked(it, true) } - listview1?.setOnItemClickListener { _, _, _, _ -> + listview1.setOnItemClickListener { _, _, _, _ -> val currentLanguages = mutableListOf() - listview1?.checkedItemPositions?.forEach { key, value -> + listview1.checkedItemPositions?.forEach { key, value -> if (value) currentLanguages.add(langs[key]) } settingsManager.edit().putStringSet( - this.getString(R.string.provider_lang_key), + ctx.getString(R.string.provider_lang_key), currentLanguages.toSet() ).apply() } - next_btt?.setOnClickListener { + nextBtt.setOnClickListener { findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) } - prev_btt?.setOnClickListener { + prevBtt.setOnClickListener { findNavController().popBackStack() - } + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt index 83d134cb..c76a218e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -13,17 +13,23 @@ import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.media3.common.text.Cue import com.fasterxml.jackson.annotation.JsonProperty -import com.google.android.exoplayer2.text.Cue -import com.google.android.gms.cast.TextTrackStyle -import com.google.android.gms.cast.TextTrackStyle.* +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DEPRESSED +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DROP_SHADOW +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.databinding.ChromecastSubtitleSettingsBinding +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -31,7 +37,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.subtitle_settings.* const val CHROME_SUBTITLE_KEY = "chome_subtitle_settings" @@ -40,12 +45,12 @@ data class SaveChromeCaptionStyle( @JsonProperty("fontGenericFamily") var fontGenericFamily: Int? = null, @JsonProperty("backgroundColor") var backgroundColor: Int = 0x00FFFFFF, // transparent @JsonProperty("edgeColor") var edgeColor: Int = Color.BLACK, // BLACK - @JsonProperty("edgeType") var edgeType: Int = TextTrackStyle.EDGE_TYPE_OUTLINE, + @JsonProperty("edgeType") var edgeType: Int = EDGE_TYPE_OUTLINE, @JsonProperty("foregroundColor") var foregroundColor: Int = Color.WHITE, @JsonProperty("fontScale") var fontScale: Float = 1.05f, @JsonProperty("windowColor") var windowColor: Int = Color.TRANSPARENT, ) - +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class ChromecastSubtitlesFragment : Fragment() { companion object { val applyStyleEvent = Event() @@ -97,7 +102,7 @@ class ChromecastSubtitlesFragment : Fragment() { } private fun onColorSelected(stuff: Pair) { - context?.setColor(stuff.first, stuff.second) + setColor(stuff.first, stuff.second) if (hide) activity?.hideSystemUI() } @@ -120,7 +125,7 @@ class ChromecastSubtitlesFragment : Fragment() { return if (color == Color.TRANSPARENT) Color.BLACK else color } - private fun Context.setColor(id: Int, color: Int?) { + private fun setColor(id: Int, color: Int?) { val realColor = color ?: getDefColor(id) when (id) { 0 -> state.foregroundColor = realColor @@ -133,16 +138,25 @@ class ChromecastSubtitlesFragment : Fragment() { updateState() } - private fun Context.updateState() { + private fun updateState() { //subtitle_text?.setStyle(fromSaveToStyle(state)) } + var binding : ChromecastSubtitleSettingsBinding? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.chromecast_subtitle_settings, container, false) + ): View { + val localBinding = ChromecastSubtitleSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.chromecast_subtitle_settings, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() } private lateinit var state: SaveChromeCaptionStyle @@ -159,12 +173,12 @@ class ChromecastSubtitlesFragment : Fragment() { onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - context?.fixPaddingStatusbar(subs_root) + fixPaddingStatusbar(binding?.subsRoot) state = getCurrentSavedStyle() - context?.updateState() + updateState() - val isTvSettings = isTvSettings() + val isTvSettings = isLayout(TV or EMULATOR) fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvSettings @@ -184,23 +198,26 @@ class ChromecastSubtitlesFragment : Fragment() { } this.setOnLongClickListener { - it.context.setColor(id, null) - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + setColor(id, null) + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } - subs_text_color.setup(0) - subs_outline_color.setup(1) - subs_background_color.setup(2) + binding?.apply { + subsTextColor.setup(0) + subsOutlineColor.setup(1) + subsBackgroundColor.setup(2) + } + val dismissCallback = { if (hide) activity?.hideSystemUI() } - subs_edge_type.setFocusableInTv() - subs_edge_type.setOnClickListener { textView -> + binding?.subsEdgeType?.setFocusableInTv() + binding?.subsEdgeType?.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -233,19 +250,19 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.edgeType = edgeTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } - subs_edge_type.setOnLongClickListener { + binding?.subsEdgeType?.setOnLongClickListener { state.edgeType = defaultState.edgeType - it.context.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - subs_font_size.setFocusableInTv() - subs_font_size.setOnClickListener { textView -> + binding?.subsFontSize?.setFocusableInTv() + binding?.subsFontSize?.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -278,24 +295,26 @@ class ChromecastSubtitlesFragment : Fragment() { } } - subs_font_size.setOnLongClickListener { _ -> + binding?.subsFontSize?.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - subs_font.setFocusableInTv() - subs_font.setOnClickListener { textView -> + + + binding?.subsFont?.setFocusableInTv() + binding?.subsFont?.setOnClickListener { textView -> val fontTypes = listOf( - Pair(null, textView.context.getString(R.string.normal)), - Pair("Droid Sans", "Droid Sans"), - Pair("Droid Sans Mono", "Droid Sans Mono"), - Pair("Droid Serif Regular", "Droid Serif Regular"), - Pair("Cutive Mono", "Cutive Mono"), - Pair("Short Stack", "Short Stack"), - Pair("Quintessential", "Quintessential"), - Pair("Alegreya Sans SC", "Alegreya Sans SC"), + null to textView.context.getString(R.string.normal), + "Droid Sans" to "Droid Sans", + "Droid Sans Mono" to "Droid Sans Mono", + "Droid Serif Regular" to "Droid Serif Regular", + "Cutive Mono" to "Cutive Mono", + "Short Stack" to "Short Stack", + "Quintessential" to "Quintessential", + "Alegreya Sans SC" to "Alegreya Sans SC", ) //showBottomDialog @@ -307,38 +326,38 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.fontFamily = fontTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } - - subs_font.setOnLongClickListener { textView -> + binding?.subsFont?.setOnLongClickListener { _ -> state.fontFamily = defaultState.fontFamily - textView.context.updateState() + updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - cancel_btt.setOnClickListener { + binding?.cancelBtt?.setOnClickListener { activity?.popCurrentPage() } - apply_btt.setOnClickListener { + binding?.applyBtt?.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } - - subtitle_text.setCues( - listOf( - Cue.Builder() - .setTextSize( - getPixels(TypedValue.COMPLEX_UNIT_SP, 25.0f).toFloat(), - Cue.TEXT_SIZE_TYPE_ABSOLUTE - ) - .setText(subtitle_text.context.getString(R.string.subtitles_example_text)) - .build() + binding?.subtitleText?.apply { + setCues( + listOf( + Cue.Builder() + .setTextSize( + getPixels(TypedValue.COMPLEX_UNIT_SP, 25.0f).toFloat(), + Cue.TEXT_SIZE_TYPE_ABSOLUTE + ) + .setText(context.getString(R.string.subtitles_example_text)) + .build() + ) ) - ) + } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index ff0e0e82..8821905e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -14,12 +14,14 @@ import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.annotation.FontRes +import androidx.annotation.OptIn import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.google.android.exoplayer2.text.Cue -import com.google.android.exoplayer2.ui.CaptionStyleCompat +import androidx.media3.common.text.Cue +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.CaptionStyleCompat import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -27,7 +29,9 @@ import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -37,15 +41,13 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.subtitle_settings.* -import kotlinx.android.synthetic.main.toast.view.* import java.io.File const val SUBTITLE_KEY = "subtitle_settings" const val SUBTITLE_AUTO_SELECT_KEY = "subs_auto_select" const val SUBTITLE_DOWNLOAD_KEY = "subs_auto_download" -data class SaveCaptionStyle( +data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( @JsonProperty("foregroundColor") var foregroundColor: Int, @JsonProperty("backgroundColor") var backgroundColor: Int, @JsonProperty("windowColor") var windowColor: Int, @@ -66,6 +68,7 @@ data class SaveCaptionStyle( const val DEF_SUBS_ELEVATION = 20 +@OptIn(androidx.media3.common.util.UnstableApi::class) class SubtitlesFragment : Fragment() { companion object { val applyStyleEvent = Event() @@ -165,7 +168,7 @@ class SubtitlesFragment : Fragment() { activity?.hideSystemUI() } - private fun onDialogDismissed(id: Int) { + private fun onDialogDismissed(@Suppress("UNUSED_PARAMETER") id: Int) { if (hide) activity?.hideSystemUI() } @@ -184,10 +187,10 @@ class SubtitlesFragment : Fragment() { } private fun Context.updateState() { - subtitle_text?.setStyle(fromSaveToStyle(state)) - val text = subtitle_text.context.getString(R.string.subtitles_example_text) + binding?.subtitleText?.setStyle(fromSaveToStyle(state)) + val text = getString(R.string.subtitles_example_text) val fixedText = if (state.upperCase) text.uppercase() else text - subtitle_text?.setCues( + binding?.subtitleText?.setCues( listOf( Cue.Builder() .setTextSize( @@ -213,12 +216,21 @@ class SubtitlesFragment : Fragment() { return if (color == Color.TRANSPARENT) Color.BLACK else color } + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + var binding: SubtitleSettingsBinding? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.subtitle_settings, container, false) + savedInstanceState: Bundle? + ): View { + val localBinding = SubtitleSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.subtitle_settings, container, false) } private lateinit var state: SaveCaptionStyle @@ -234,16 +246,16 @@ class SubtitlesFragment : Fragment() { hide = arguments?.getBoolean("hide") ?: true onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - subs_import_text?.text = getString(R.string.subs_import_text).format( + binding?.subsImportText?.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) - context?.fixPaddingStatusbar(subs_root) + fixPaddingStatusbar(binding?.subsRoot) state = getCurrentSavedStyle() context?.updateState() - val isTvTrueSettings = isTrueTvSettings() + val isTvTrueSettings = isLayout(TV) fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings @@ -264,317 +276,318 @@ class SubtitlesFragment : Fragment() { this.setOnLongClickListener { it.context.setColor(id, null) - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } + binding?.apply { + subsTextColor.setup(0) + subsOutlineColor.setup(1) + subsBackgroundColor.setup(2) + subsWindowColor.setup(3) - subs_text_color.setup(0) - subs_outline_color.setup(1) - subs_background_color.setup(2) - subs_window_color.setup(3) - - val dismissCallback = { - if (hide) - activity?.hideSystemUI() - } - - subs_subtitle_elevation.setFocusableInTv() - subs_subtitle_elevation.setOnClickListener { textView -> - val suffix = "dp" - val elevationTypes = listOf( - Pair(0, textView.context.getString(R.string.none)), - Pair(10, "10$suffix"), - Pair(20, "20$suffix"), - Pair(30, "30$suffix"), - Pair(40, "40$suffix"), - Pair(50, "50$suffix"), - Pair(60, "60$suffix"), - Pair(70, "70$suffix"), - Pair(80, "80$suffix"), - Pair(90, "90$suffix"), - Pair(100, "100$suffix"), - ) - - //showBottomDialog - activity?.showDialog( - elevationTypes.map { it.second }, - elevationTypes.map { it.first }.indexOf(state.elevation), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.elevation = elevationTypes.map { it.first }[index] - textView.context.updateState() + val dismissCallback = { if (hide) activity?.hideSystemUI() } - } - subs_subtitle_elevation.setOnLongClickListener { - state.elevation = DEF_SUBS_ELEVATION - it.context.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + subsSubtitleElevation.setFocusableInTv() + subsSubtitleElevation.setOnClickListener { textView -> + val suffix = "dp" + val elevationTypes = listOf( + Pair(0, textView.context.getString(R.string.none)), + Pair(10, "10$suffix"), + Pair(20, "20$suffix"), + Pair(30, "30$suffix"), + Pair(40, "40$suffix"), + Pair(50, "50$suffix"), + Pair(60, "60$suffix"), + Pair(70, "70$suffix"), + Pair(80, "80$suffix"), + Pair(90, "90$suffix"), + Pair(100, "100$suffix"), + ) - subs_edge_type.setFocusableInTv() - subs_edge_type.setOnClickListener { textView -> - val edgeTypes = listOf( - Pair( - CaptionStyleCompat.EDGE_TYPE_NONE, - textView.context.getString(R.string.subtitles_none) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_OUTLINE, - textView.context.getString(R.string.subtitles_outline) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_DEPRESSED, - textView.context.getString(R.string.subtitles_depressed) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW, - textView.context.getString(R.string.subtitles_shadow) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_RAISED, - textView.context.getString(R.string.subtitles_raised) - ), - ) - - //showBottomDialog - activity?.showDialog( - edgeTypes.map { it.second }, - edgeTypes.map { it.first }.indexOf(state.edgeType), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.edgeType = edgeTypes.map { it.first }[index] - textView.context.updateState() - } - } - - subs_edge_type.setOnLongClickListener { - state.edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE - it.context.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } - - subs_font_size.setFocusableInTv() - subs_font_size.setOnClickListener { textView -> - val suffix = "sp" - val fontSizes = listOf( - Pair(null, textView.context.getString(R.string.normal)), - Pair(6f, "6$suffix"), - Pair(7f, "7$suffix"), - Pair(8f, "8$suffix"), - Pair(9f, "9$suffix"), - Pair(10f, "10$suffix"), - Pair(11f, "11$suffix"), - Pair(12f, "12$suffix"), - Pair(13f, "13$suffix"), - Pair(14f, "14$suffix"), - Pair(15f, "15$suffix"), - Pair(16f, "16$suffix"), - Pair(17f, "17$suffix"), - Pair(18f, "18$suffix"), - Pair(19f, "19$suffix"), - Pair(20f, "20$suffix"), - Pair(21f, "21$suffix"), - Pair(22f, "22$suffix"), - Pair(23f, "23$suffix"), - Pair(24f, "24$suffix"), - Pair(25f, "25$suffix"), - Pair(26f, "26$suffix"), - Pair(28f, "28$suffix"), - Pair(30f, "30$suffix"), - Pair(32f, "32$suffix"), - Pair(34f, "34$suffix"), - Pair(36f, "36$suffix"), - Pair(38f, "38$suffix"), - Pair(40f, "40$suffix"), - Pair(42f, "42$suffix"), - Pair(44f, "44$suffix"), - Pair(48f, "48$suffix"), - Pair(60f, "60$suffix"), - ) - - //showBottomDialog - activity?.showDialog( - fontSizes.map { it.second }, - fontSizes.map { it.first }.indexOf(state.fixedTextSize), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.fixedTextSize = fontSizes.map { it.first }[index] - //textView.context.updateState() // font size not changed - } - } - - subtitles_remove_bloat?.isChecked = state.removeBloat - subtitles_remove_bloat?.setOnCheckedChangeListener { _, b -> - state.removeBloat = b - } - subtitles_uppercase?.isChecked = state.upperCase - subtitles_uppercase?.setOnCheckedChangeListener { _, b -> - state.upperCase = b - context?.updateState() - } - - subtitles_remove_captions?.isChecked = state.removeCaptions - subtitles_remove_captions?.setOnCheckedChangeListener { _, b -> - state.removeCaptions = b - } - - subs_font_size.setOnLongClickListener { _ -> - state.fixedTextSize = null - //textView.context.updateState() // font size not changed - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } - - //Fetch current value from preference - context?.let { ctx -> - subtitles_filter_sub_lang?.isChecked = - PreferenceManager.getDefaultSharedPreferences(ctx) - .getBoolean(getString(R.string.filter_sub_lang_key), false) - } - - subtitles_filter_sub_lang?.setOnCheckedChangeListener { _, b -> - context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit() - .putBoolean(getString(R.string.filter_sub_lang_key), b) - .apply() - } - } - - subs_font.setFocusableInTv() - subs_font.setOnClickListener { textView -> - val fontTypes = listOf( - Pair(null, textView.context.getString(R.string.normal)), - Pair(R.font.trebuchet_ms, "Trebuchet MS"), - Pair(R.font.netflix_sans, "Netflix Sans"), - Pair(R.font.google_sans, "Google Sans"), - Pair(R.font.open_sans, "Open Sans"), - Pair(R.font.futura, "Futura"), - Pair(R.font.consola, "Consola"), - Pair(R.font.gotham, "Gotham"), - Pair(R.font.lucida_grande, "Lucida Grande"), - Pair(R.font.stix_general, "STIX General"), - Pair(R.font.times_new_roman, "Times New Roman"), - Pair(R.font.verdana, "Verdana"), - Pair(R.font.ubuntu_regular, "Ubuntu"), - Pair(R.font.comic_sans, "Comic Sans"), - Pair(R.font.poppins_regular, "Poppins"), - ) - val savedFontTypes = textView.context.getSavedFonts() - - val currentIndex = - savedFontTypes.indexOfFirst { it.absolutePath == state.typefaceFilePath } - .let { index -> - if (index == -1) - fontTypes.indexOfFirst { it.first == state.typeface } - else index + fontTypes.size - } - - //showBottomDialog - activity?.showDialog( - fontTypes.map { it.second } + savedFontTypes.map { it.name }, - currentIndex, - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - if (index < fontTypes.size) { - state.typeface = fontTypes[index].first - state.typefaceFilePath = null - } else { - state.typefaceFilePath = savedFontTypes[index - fontTypes.size].absolutePath - state.typeface = null + //showBottomDialog + activity?.showDialog( + elevationTypes.map { it.second }, + elevationTypes.map { it.first }.indexOf(state.elevation), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.elevation = elevationTypes.map { it.first }[index] + textView.context.updateState() + if (hide) + activity?.hideSystemUI() } + } + + subsSubtitleElevation.setOnLongClickListener { + state.elevation = DEF_SUBS_ELEVATION + it.context.updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + subsEdgeType.setFocusableInTv() + subsEdgeType.setOnClickListener { textView -> + val edgeTypes = listOf( + Pair( + CaptionStyleCompat.EDGE_TYPE_NONE, + textView.context.getString(R.string.subtitles_none) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_OUTLINE, + textView.context.getString(R.string.subtitles_outline) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_DEPRESSED, + textView.context.getString(R.string.subtitles_depressed) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW, + textView.context.getString(R.string.subtitles_shadow) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_RAISED, + textView.context.getString(R.string.subtitles_raised) + ), + ) + + //showBottomDialog + activity?.showDialog( + edgeTypes.map { it.second }, + edgeTypes.map { it.first }.indexOf(state.edgeType), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.edgeType = edgeTypes.map { it.first }[index] + textView.context.updateState() + } + } + + subsEdgeType.setOnLongClickListener { + state.edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE + it.context.updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + subsFontSize.setFocusableInTv() + subsFontSize.setOnClickListener { textView -> + val suffix = "sp" + val fontSizes = listOf( + Pair(null, textView.context.getString(R.string.normal)), + Pair(6f, "6$suffix"), + Pair(7f, "7$suffix"), + Pair(8f, "8$suffix"), + Pair(9f, "9$suffix"), + Pair(10f, "10$suffix"), + Pair(11f, "11$suffix"), + Pair(12f, "12$suffix"), + Pair(13f, "13$suffix"), + Pair(14f, "14$suffix"), + Pair(15f, "15$suffix"), + Pair(16f, "16$suffix"), + Pair(17f, "17$suffix"), + Pair(18f, "18$suffix"), + Pair(19f, "19$suffix"), + Pair(20f, "20$suffix"), + Pair(21f, "21$suffix"), + Pair(22f, "22$suffix"), + Pair(23f, "23$suffix"), + Pair(24f, "24$suffix"), + Pair(25f, "25$suffix"), + Pair(26f, "26$suffix"), + Pair(28f, "28$suffix"), + Pair(30f, "30$suffix"), + Pair(32f, "32$suffix"), + Pair(34f, "34$suffix"), + Pair(36f, "36$suffix"), + Pair(38f, "38$suffix"), + Pair(40f, "40$suffix"), + Pair(42f, "42$suffix"), + Pair(44f, "44$suffix"), + Pair(48f, "48$suffix"), + Pair(60f, "60$suffix"), + ) + + //showBottomDialog + activity?.showDialog( + fontSizes.map { it.second }, + fontSizes.map { it.first }.indexOf(state.fixedTextSize), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.fixedTextSize = fontSizes.map { it.first }[index] + //textView.context.updateState() // font size not changed + } + } + + subtitlesRemoveBloat.isChecked = state.removeBloat + subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> + state.removeBloat = b + } + subtitlesUppercase.isChecked = state.upperCase + subtitlesUppercase.setOnCheckedChangeListener { _, b -> + state.upperCase = b + context?.updateState() + } + + subtitlesRemoveCaptions.isChecked = state.removeCaptions + subtitlesRemoveCaptions.setOnCheckedChangeListener { _, b -> + state.removeCaptions = b + } + + subsFontSize.setOnLongClickListener { _ -> + state.fixedTextSize = null + //textView.context.updateState() // font size not changed + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + //Fetch current value from preference + context?.let { ctx -> + subtitlesFilterSubLang.isChecked = + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(getString(R.string.filter_sub_lang_key), false) + } + + subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> + context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit() + .putBoolean(getString(R.string.filter_sub_lang_key), b) + .apply() + } + } + + subsFont.setFocusableInTv() + subsFont.setOnClickListener { textView -> + val fontTypes = listOf( + Pair(null, textView.context.getString(R.string.normal)), + Pair(R.font.trebuchet_ms, "Trebuchet MS"), + Pair(R.font.netflix_sans, "Netflix Sans"), + Pair(R.font.google_sans, "Google Sans"), + Pair(R.font.open_sans, "Open Sans"), + Pair(R.font.futura, "Futura"), + Pair(R.font.consola, "Consola"), + Pair(R.font.gotham, "Gotham"), + Pair(R.font.lucida_grande, "Lucida Grande"), + Pair(R.font.stix_general, "STIX General"), + Pair(R.font.times_new_roman, "Times New Roman"), + Pair(R.font.verdana, "Verdana"), + Pair(R.font.ubuntu_regular, "Ubuntu"), + Pair(R.font.comic_sans, "Comic Sans"), + Pair(R.font.poppins_regular, "Poppins"), + ) + val savedFontTypes = textView.context.getSavedFonts() + + val currentIndex = + savedFontTypes.indexOfFirst { it.absolutePath == state.typefaceFilePath } + .let { index -> + if (index == -1) + fontTypes.indexOfFirst { it.first == state.typeface } + else index + fontTypes.size + } + + //showBottomDialog + activity?.showDialog( + fontTypes.map { it.second } + savedFontTypes.map { it.name }, + currentIndex, + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + if (index < fontTypes.size) { + state.typeface = fontTypes[index].first + state.typefaceFilePath = null + } else { + state.typefaceFilePath = savedFontTypes[index - fontTypes.size].absolutePath + state.typeface = null + } + textView.context.updateState() + } + } + + subsFont.setOnLongClickListener { textView -> + state.typeface = null + state.typefaceFilePath = null textView.context.updateState() + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true } - } - subs_font.setOnLongClickListener { textView -> - state.typeface = null - state.typefaceFilePath = null - textView.context.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + subsAutoSelectLanguage.setFocusableInTv() + subsAutoSelectLanguage.setOnClickListener { textView -> + val langMap = arrayListOf( + SubtitleHelper.Language639( + textView.context.getString(R.string.none), + textView.context.getString(R.string.none), + "", + "", + "", + "", + "" + ), + ) + langMap.addAll(SubtitleHelper.languages) - subs_auto_select_language.setFocusableInTv() - subs_auto_select_language.setOnClickListener { textView -> - val langMap = arrayListOf( - SubtitleHelper.Language639( - textView.context.getString(R.string.none), - textView.context.getString(R.string.none), - "", - "", - "", - "", - "" - ), - ) - langMap.addAll(SubtitleHelper.languages) - - val lang639_1 = langMap.map { it.ISO_639_1 } - activity?.showDialog( - langMap.map { it.languageName }, - lang639_1.indexOf(getAutoSelectLanguageISO639_1()), - (textView as TextView).text.toString(), - true, - dismissCallback - ) { index -> - setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) + val lang639_1 = langMap.map { it.ISO_639_1 } + activity?.showDialog( + langMap.map { it.languageName }, + lang639_1.indexOf(getAutoSelectLanguageISO639_1()), + (textView as TextView).text.toString(), + true, + dismissCallback + ) { index -> + setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) + } } - } - subs_auto_select_language.setOnLongClickListener { - setKey(SUBTITLE_AUTO_SELECT_KEY, "en") - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } - - subs_download_languages.setFocusableInTv() - subs_download_languages.setOnClickListener { textView -> - val langMap = SubtitleHelper.languages - val lang639_1 = langMap.map { it.ISO_639_1 } - val keys = getDownloadSubsLanguageISO639_1() - val keyMap = keys.map { lang639_1.indexOf(it) }.filter { it >= 0 } - - activity?.showMultiDialog( - langMap.map { it.languageName }, - keyMap, - (textView as TextView).text.toString(), - dismissCallback - ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) + subsAutoSelectLanguage.setOnLongClickListener { + setKey(SUBTITLE_AUTO_SELECT_KEY, "en") + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true } - } - subs_download_languages.setOnLongClickListener { - setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) + subsDownloadLanguages.setFocusableInTv() + subsDownloadLanguages.setOnClickListener { textView -> + val langMap = SubtitleHelper.languages + val lang639_1 = langMap.map { it.ISO_639_1 } + val keys = getDownloadSubsLanguageISO639_1() + val keyMap = keys.map { lang639_1.indexOf(it) }.filter { it >= 0 } - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + activity?.showMultiDialog( + langMap.map { it.languageName }, + keyMap, + (textView as TextView).text.toString(), + dismissCallback + ) { indexList -> + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) + } + } - cancel_btt.setOnClickListener { - activity?.popCurrentPage() - } + subsDownloadLanguages.setOnLongClickListener { + setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) - apply_btt.setOnClickListener { - it.context.saveStyle(state) - applyStyleEvent.invoke(state) - it.context.fromSaveToStyle(state) - activity?.popCurrentPage() + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + cancelBtt.setOnClickListener { + activity?.popCurrentPage() + } + + applyBtt.setOnClickListener { + it.context.saveStyle(state) + applyStyleEvent.invoke(state) + it.context.fromSaveToStyle(state) + activity?.popCurrentPage() + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt index e9b69c5b..f0c948a4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt @@ -83,7 +83,7 @@ object EpisodeSkip { startMs = start, endMs = end ) - }?.let { list -> + }.let { list -> out.addAll(list) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt similarity index 71% rename from app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 00dee9b2..8d65acf7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -1,8 +1,11 @@ package com.lagradost.cloudstream3.utils +import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Activity.RESULT_CANCELED +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.* import android.content.pm.PackageManager import android.database.Cursor @@ -17,6 +20,10 @@ import android.os.* import android.provider.MediaStore import android.text.Spanned import android.util.Log +import android.view.View +import android.view.View.LAYOUT_DIRECTION_LTR +import android.view.View.LAYOUT_DIRECTION_RTL +import android.view.animation.DecelerateInterpolator import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi @@ -25,16 +32,17 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned +import androidx.core.widget.ContentLoadingProgressBar import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.* import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor import androidx.viewpager2.widget.ViewPager2 -import com.fasterxml.jackson.module.kotlin.readValue import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.common.ConnectionResult @@ -42,17 +50,20 @@ import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.wrappers.Wrappers import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.apis +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING +import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.WebviewFragment +import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel.Companion.downloadAll +import com.lagradost.cloudstream3.ui.settings.Globals +import com.lagradost.cloudstream3.ui.settings.extensions.PluginsFragment import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main @@ -67,9 +78,8 @@ import okhttp3.Cache import java.io.* import java.net.URL import java.net.URLDecoder -import kotlin.system.measureTimeMillis -object AppUtils { +object AppContextUtils { fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { for (i in 0..maxViewTypeId) recycledViewPool.setMaxRecycledViews(i, maxPoolSize) @@ -82,6 +92,9 @@ object AppUtils { return if (layoutManager == null || adapter == null) false else layoutManager.findLastCompletelyVisibleItemPosition() < adapter.itemCount - 7 // bit more than 1 to make it more seamless } + fun View.isLtr() = this.layoutDirection == LAYOUT_DIRECTION_LTR + fun View.isRtl() = this.layoutDirection == LAYOUT_DIRECTION_RTL + fun BottomSheetDialog?.ownHide() { this?.hide() } @@ -147,7 +160,7 @@ object AppUtils { .setTitle(title) .setPosterArtUri(Uri.parse(card.posterUrl)) .setIntentUri(Uri.parse(card.id?.let { - "$appStringResumeWatching://$it" + "$APP_STRING_RESUME_WATCHING://$it" } ?: card.url)) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( @@ -179,6 +192,40 @@ object AppUtils { touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally } + fun ContentLoadingProgressBar?.animateProgressTo(to: Int) { + if (this == null) return + val animation: ObjectAnimator = ObjectAnimator.ofInt( + this, + "progress", + this.progress, + to + ) + animation.duration = 500 + animation.setAutoCancel(true) + animation.interpolator = DecelerateInterpolator() + animation.start() + } + + fun Context.createNotificationChannel( + channelId: String, + channelName: String, + description: String + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel(channelId, channelName, importance).apply { + this.description = description + } + + // Register the channel with the system. + val notificationManager: NotificationManager = + this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(channel) + } + } + @SuppressLint("RestrictedApi") fun getAllWatchNextPrograms(context: Context): Set { val COLUMN_WATCH_NEXT_ID_INDEX = 0 @@ -253,6 +300,7 @@ object AppUtils { // https://github.com/googlearchive/leanback-homescreen-channels/blob/master/app/src/main/java/com/google/android/tvhomescreenchannels/SampleTvProvider.java @SuppressLint("RestrictedApi") + @Throws @WorkerThread suspend fun Context.addProgramsToContinueWatching(data: List) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return @@ -323,6 +371,168 @@ object AppUtils { } } + fun sortSubs(subs: Set): List { + return subs.sortedBy { it.name } + } + + fun Context.getApiSettings(): HashSet { + //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + + val hashSet = HashSet() + val activeLangs = getApiProviderLangSettings() + val hasUniversal = activeLangs.contains(AllLanguagesName) + hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } } + .map { it.name }) + + /*val set = settingsManager.getStringSet( + this.getString(R.string.search_providers_list_key), + hashSet + )?.toHashSet() ?: hashSet + + val list = HashSet() + for (name in set) { + val api = getApiFromNameNull(name) ?: continue + if (activeLangs.contains(api.lang)) { + list.add(name) + } + }*/ + //if (list.isEmpty()) return hashSet + //return list + return hashSet + } + + fun Context.getApiDubstatusSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() + hashSet.addAll(DubStatus.values()) + val list = settingsManager.getStringSet( + this.getString(R.string.display_sub_key), + hashSet.map { it.name }.toMutableSet() + ) ?: return hashSet + + val names = DubStatus.values().map { it.name }.toHashSet() + //if(realSet.isEmpty()) return hashSet + + return list.filter { names.contains(it) }.map { DubStatus.valueOf(it) }.toHashSet() + } + + fun Context.getApiProviderLangSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = hashSetOf(AllLanguagesName) // def is all languages +// hashSet.add("en") // def is only en + val list = settingsManager.getStringSet( + this.getString(R.string.provider_lang_key), + hashSet + ) + + if (list.isNullOrEmpty()) return hashSet + return list.toHashSet() + } + + fun Context.getApiTypeSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() + hashSet.addAll(TvType.values()) + val list = settingsManager.getStringSet( + this.getString(R.string.search_types_list_key), + hashSet.map { it.name }.toMutableSet() + ) + + if (list.isNullOrEmpty()) return hashSet + + val names = TvType.values().map { it.name }.toHashSet() + val realSet = list.filter { names.contains(it) }.map { TvType.valueOf(it) }.toHashSet() + if (realSet.isEmpty()) return hashSet + + return realSet + } + + fun Context.updateHasTrailers() { + LoadResponse.isTrailersEnabled = getHasTrailers() + } + + private fun Context.getHasTrailers(): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true) + } + + fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List { + // We are getting the weirdest crash ever done: + // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType + // Trying fixing using classloader fuckery + val oldLoader = Thread.currentThread().contextClassLoader + Thread.currentThread().contextClassLoader = TvType::class.java.classLoader + + val default = TvType.values() + .sorted() + .filter { it != TvType.NSFW } + .map { it.ordinal } + + Thread.currentThread().contextClassLoader = oldLoader + + val defaultSet = default.map { it.toString() }.toSet() + val currentPrefMedia = try { + PreferenceManager.getDefaultSharedPreferences(this) + .getStringSet(this.getString(R.string.prefer_media_type_key), defaultSet) + ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } + } catch (e: Throwable) { + null + } ?: default + val langs = this.getApiProviderLangSettings() + val hasUniversal = langs.contains(AllLanguagesName) + val allApis = synchronized(apis) { + apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + } + return if (currentPrefMedia.isEmpty()) { + allApis + } else { + // Filter API depending on preferred media type + allApis.filter { api -> api.supportedTypes.any { currentPrefMedia.contains(it.ordinal) } } + } + } + + fun Context.filterSearchResultByFilmQuality(data: List): List { + // Filter results omitting entries with certain quality + if (data.isNotEmpty()) { + val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) + ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) + ?.mapNotNull { entry -> + entry.toIntOrNull() ?: return@mapNotNull null + } ?: listOf() + if (filteredSearchQuality.isNotEmpty()) { + return data.filter { item -> + val searchQualVal = item.quality?.ordinal ?: -1 + //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") + !filteredSearchQuality.contains(searchQualVal) + } + } + } + return data + } + + fun Context.filterHomePageListByFilmQuality(data: HomePageList): HomePageList { + // Filter results omitting entries with certain quality + if (data.list.isNotEmpty()) { + val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) + ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) + ?.mapNotNull { entry -> + entry.toIntOrNull() ?: return@mapNotNull null + } ?: listOf() + if (filteredSearchQuality.isNotEmpty()) { + return HomePageList( + name = data.name, + isHorizontalImages = data.isHorizontalImages, + list = data.list.filter { item -> + val searchQualVal = item.quality?.ordinal ?: -1 + //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") + !filteredSearchQuality.contains(searchQualVal) + } + ) + } + } + return data + } + fun Activity.loadRepository(url: String) { ioSafe { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe @@ -334,13 +544,12 @@ object AppUtils { ) main { showToast( - this@loadRepository, getString(R.string.player_loaded_subtitles, repo.name), Toast.LENGTH_LONG ) } afterRepositoryLoadedEvent.invoke(true) - downloadAllPluginsDialog(url, repo.name) + addRepositoryDialog(repo.name, url) } } @@ -383,25 +592,36 @@ object AppUtils { } } + fun Activity.addRepositoryDialog( + repositoryName: String, + repositoryURL: String, + ) { + val repos = RepositoryManager.getRepositories() - fun Activity.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) { - runOnUiThread { - val context = this - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle( - repositoryName - ) - builder.setMessage( - R.string.download_all_plugins_from_repo - ) - builder.apply { - setPositiveButton(R.string.download) { _, _ -> - downloadAll(context, repositoryUrl, null) - } - - setNegativeButton(R.string.no) { _, _ -> } + // navigate to newly added repository on pressing Open Repository + fun openAddedRepo() { + if (repos.isNotEmpty()) { + navigate( + R.id.global_to_navigation_settings_plugins, + PluginsFragment.newInstance( + repositoryName, + repositoryURL, + false, + ) + ) + } + } + + runOnUiThread { + AlertDialog.Builder(this).apply { + setTitle(repositoryName) + setMessage(R.string.download_all_plugins_from_repo) + setPositiveButton(R.string.open_downloaded_repo) { _, _ -> + openAddedRepo() + } + setNegativeButton(R.string.dismiss, null) + show().setDefaultFocus() } - builder.show().setDefaultFocus() } } @@ -456,6 +676,18 @@ object AppUtils { } } + fun Context.isNetworkAvailable(): Boolean { + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } else { + @Suppress("DEPRECATION") + connectivityManager.activeNetworkInfo?.isConnected == true + } + } + fun splitQuery(url: URL): Map { val queryPairs: MutableMap = LinkedHashMap() val query: String = url.query @@ -468,24 +700,6 @@ object AppUtils { return queryPairs } - /** Any object as json string */ - fun Any.toJson(): String { - if (this is String) return this - return mapper.writeValueAsString(this) - } - - inline fun parseJson(value: String): T { - return mapper.readValue(value) - } - - inline fun tryParseJson(value: String?): T? { - return try { - parseJson(value ?: return null) - } catch (_: Exception) { - null - } - } - /**| S1:E2 Hello World * | Episode 2. Hello world * | Hello World @@ -529,19 +743,36 @@ object AppUtils { //private val viewModel: ResultViewModel by activityViewModels() private fun getResultsId(): Int { - return if (isTrueTvSettings()) { + return if (Globals.isLayout(Globals.TV or Globals.EMULATOR)) { R.id.global_to_navigation_results_tv } else { R.id.global_to_navigation_results_phone } } + fun loadResult( + url: String, + apiName: String, + startAction: Int = 0, + startValue: Int = 0 + ) { + (activity as FragmentActivity?)?.loadResult(url, apiName, startAction, startValue) + } + fun FragmentActivity.loadResult( url: String, apiName: String, startAction: Int = 0, startValue: Int = 0 ) { + try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + Kitsu.isEnabled = + settingsManager.getBoolean(this.getString(R.string.show_kitsu_posters_key), true) + } catch (t: Throwable) { + logError(t) + } + this.runOnUiThread { // viewModelStore.clear() this.navigate( @@ -551,6 +782,14 @@ object AppUtils { } } + fun loadSearchResult( + card: SearchResponse, + startAction: Int = 0, + startValue: Int? = null, + ) { + activity?.loadSearchResult(card, startAction, startValue) + } + fun Activity?.loadSearchResult( card: SearchResponse, startAction: Int = 0, @@ -628,7 +867,7 @@ object AppUtils { * Sets the focus to the negative button when in TV and Emulator layout. **/ fun AlertDialog.setDefaultFocus(buttonFocus: Int = DialogInterface.BUTTON_NEGATIVE) { - if (!isTvSettings()) return + if (!Globals.isLayout(Globals.TV or Globals.EMULATOR)) return this.getButton(buttonFocus).run { isFocusableInTouchMode = true requestFocus() @@ -735,9 +974,14 @@ object AppUtils { return networkInfo.any { conManager.getNetworkCapabilities(it) ?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true - } + } && + !networkInfo.any { + conManager.getNetworkCapabilities(it) + ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } } + private fun Activity?.cacheClass(clazz: String?) { clazz?.let { c -> this?.cacheDir?.let { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt new file mode 100644 index 00000000..1326ab27 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.utils + +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback + +object BackPressedCallbackHelper { + private var backPressedCallback: OnBackPressedCallback? = null + + fun ComponentActivity.attachBackPressedCallback(callback: () -> Unit) { + if (backPressedCallback == null) { + backPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + callback.invoke() + } + } + } + + backPressedCallback?.isEnabled = true + + onBackPressedDispatcher.addCallback( + this@attachBackPressedCallback, + backPressedCallback ?: return + ) + } + + fun detachBackPressedCallback() { + backPressedCallback?.isEnabled = false + backPressedCallback = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 2318fda6..b25be59f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,11 +1,8 @@ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint -import android.content.ContentValues import android.content.Context import android.net.Uri -import android.os.Build -import android.provider.MediaStore import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -13,6 +10,7 @@ import androidx.annotation.WorkerThread import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -28,21 +26,22 @@ import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_T import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_UNIXTIME_KEY import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_USER_KEY import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi.Companion.OPEN_SUBTITLES_USER_KEY +import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi.Companion.SUBDL_SUBTITLES_USER_KEY +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.mapper -import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.VideoDownloadManager.isDownloadDir -import java.io.IOException +import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream +import okhttp3.internal.closeQuietly +import java.io.OutputStream import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat -import java.util.* +import java.util.Date object BackupUtils { @@ -66,24 +65,28 @@ object BackupUtils { PLUGINS_KEY_LOCAL, OPEN_SUBTITLES_USER_KEY, + SUBDL_SUBTITLES_USER_KEY, + + "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key + "download_path_key" // No access rights after restore data from backup ) - /** false if blacklisted key */ + /** false if key should not be contained in backup */ private fun String.isTransferable(): Boolean { - return !nonTransferableKeys.contains(this) + return !nonTransferableKeys.any { this.contains(it) } } private var restoreFileSelector: ActivityResultLauncher>? = null // Kinda hack, but I couldn't think of a better way data class BackupVars( - @JsonProperty("_Bool") val _Bool: Map?, - @JsonProperty("_Int") val _Int: Map?, - @JsonProperty("_String") val _String: Map?, - @JsonProperty("_Float") val _Float: Map?, - @JsonProperty("_Long") val _Long: Map?, - @JsonProperty("_StringSet") val _StringSet: Map?>?, + @JsonProperty("_Bool") val bool: Map?, + @JsonProperty("_Int") val int: Map?, + @JsonProperty("_String") val string: Map?, + @JsonProperty("_Float") val float: Map?, + @JsonProperty("_Long") val long: Map?, + @JsonProperty("_StringSet") val stringSet: Map?>?, ) data class BackupFile( @@ -92,9 +95,11 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - fun Context.getBackup(): BackupFile { - val allData = getSharedPrefs().all.filter { it.key.isTransferable() } - val allSettings = getDefaultSharedPrefs().all.filter { it.key.isTransferable() } + private fun getBackup(context: Context?): BackupFile? { + if (context == null) return null + + val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } + val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, @@ -121,87 +126,56 @@ object BackupUtils { } @WorkerThread - fun Context.restore( + fun restore( + context: Context?, backupFile: BackupFile, restoreSettings: Boolean, restoreDataStore: Boolean ) { + if (context == null) return if (restoreSettings) { - restoreMap(backupFile.settings._Bool, true) - restoreMap(backupFile.settings._Int, true) - restoreMap(backupFile.settings._String, true) - restoreMap(backupFile.settings._Float, true) - restoreMap(backupFile.settings._Long, true) - restoreMap(backupFile.settings._StringSet, true) + context.restoreMap(backupFile.settings.bool, true) + context.restoreMap(backupFile.settings.int, true) + context.restoreMap(backupFile.settings.string, true) + context.restoreMap(backupFile.settings.float, true) + context.restoreMap(backupFile.settings.long, true) + context.restoreMap(backupFile.settings.stringSet, true) } if (restoreDataStore) { - restoreMap(backupFile.datastore._Bool) - restoreMap(backupFile.datastore._Int) - restoreMap(backupFile.datastore._String) - restoreMap(backupFile.datastore._Float) - restoreMap(backupFile.datastore._Long) - restoreMap(backupFile.datastore._StringSet) + context.restoreMap(backupFile.datastore.bool) + context.restoreMap(backupFile.datastore.int) + context.restoreMap(backupFile.datastore.string) + context.restoreMap(backupFile.datastore.float) + context.restoreMap(backupFile.datastore.long) + context.restoreMap(backupFile.datastore.stringSet) } } @SuppressLint("SimpleDateFormat") - fun FragmentActivity.backup() { + fun backup(context: Context?) = ioSafe { + if (context == null) return@ioSafe + + var fileStream: OutputStream? = null + var printStream: PrintWriter? = null try { - if (!checkWrite()) { - showToast(this, getString(R.string.backup_failed), Toast.LENGTH_LONG) - requestRW() - return + if (!context.checkWrite()) { + showToast(R.string.backup_failed, Toast.LENGTH_LONG) + context.getActivity()?.requestRW() + return@ioSafe } - val subDir = getBasePath().first val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) - val ext = "json" + val ext = "txt" val displayName = "CS3_Backup_${date}" - val backupFile = getBackup() + val backupFile = getBackup(context) + val stream = setupStream(context, displayName, null, ext, false) - val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && subDir?.isDownloadDir() == true - ) { - val cr = this.contentResolver - val contentUri = - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI - //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, displayName) - // While it a json file we store as txt because not - // all file managers support mimetype json - put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") - //put(MediaStore.MediaColumns.RELATIVE_PATH, folder) - } - - val newFileUri = cr.insert( - contentUri, - newFile - ) ?: throw IOException("Error creating file uri") - cr.openOutputStream(newFileUri, "w") - ?: throw IOException("Error opening stream") - } else { - val fileName = "$displayName.$ext" - val rFile = subDir?.findFile(fileName) - if (rFile?.exists() == true) { - rFile.delete() - } - val file = - subDir?.createFile(fileName) - ?: throw IOException("Error creating file") - if (!file.exists()) throw IOException("File does not exist") - file.openOutputStream() - } - - val printStream = PrintWriter(steam) + fileStream = stream.openNew() + printStream = PrintWriter(fileStream) printStream.print(mapper.writeValueAsString(backupFile)) - printStream.close() showToast( - this, R.string.backup_success, Toast.LENGTH_LONG ) @@ -209,13 +183,15 @@ object BackupUtils { logError(e) try { showToast( - this, - getString(R.string.backup_failed_error_format).format(e.toString()), + txt(R.string.backup_failed_error_format, e.toString()), Toast.LENGTH_LONG ) } catch (e: Exception) { logError(e) } + } finally { + printStream?.closeQuietly() + fileStream?.closeQuietly() } } @@ -233,7 +209,8 @@ object BackupUtils { val restoredValue = mapper.readValue(input) - activity.restore( + restore( + activity, restoredValue, restoreSettings = true, restoreDataStore = true @@ -243,7 +220,6 @@ object BackupUtils { logError(e) main { // smth can fail in .format showToast( - activity, getString(R.string.restore_failed_format).format(e.toString()) ) } @@ -270,7 +246,7 @@ object BackupUtils { ) ) } catch (e: Exception) { - showToast(this, e.message) + showToast(e.message) logError(e) } } @@ -280,8 +256,12 @@ object BackupUtils { map: Map?, isEditingAppSettings: Boolean = false ) { - map?.filter { it.key.isTransferable() }?.forEach { - setKeyRaw(it.key, it.value, isEditingAppSettings) + val editor = DataStore.editor(this, isEditingAppSettings) + map?.forEach { + if (it.key.isTransferable()) { + editor.setKeyRaw(it.key, it.value) + } } + editor.apply() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt new file mode 100644 index 00000000..45acbab4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt @@ -0,0 +1,172 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getString +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R + +object BiometricAuthenticator { + + const val TAG = "cs3Auth" + private const val MAX_FAILED_ATTEMPTS = 3 + private var failedAttempts = 0 + private var biometricManager: BiometricManager? = null + var biometricPrompt: BiometricPrompt? = null + var promptInfo: BiometricPrompt.PromptInfo? = null + var authCallback: BiometricCallback? = null // listen to authentication success + + private fun initializeBiometrics(activity: Activity) { + val executor = ContextCompat.getMainExecutor(activity) + + biometricManager = BiometricManager.from(activity) + + biometricPrompt = BiometricPrompt( + activity as FragmentActivity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + showToast("$errString") + Log.e(TAG, "$errorCode") + authCallback?.onAuthenticationError() + //activity.finish() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + failedAttempts = 0 + authCallback?.onAuthenticationSuccess() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + failedAttempts++ + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + failedAttempts = 0 + activity.finish() + } + } + }) + } + + @Suppress("DEPRECATION") + // authentication dialog prompt builder + private fun authenticationDialog( + activity: Activity, + title: Int, + setDeviceCred: Boolean, + ) { + val description = activity.getString(R.string.biometric_prompt_description) + + if (setDeviceCred) { + // For API level > 30, Newer API setAllowedAuthenticators is used + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + + val authFlag = DEVICE_CREDENTIAL or BIOMETRIC_WEAK or BIOMETRIC_STRONG + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setAllowedAuthenticators(authFlag) + .build() + } else { + // for apis < 30 + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setDeviceCredentialAllowed(true) + .build() + } + } else { + // fallback for A12+ when both fingerprint & Face unlock is absent but PIN is set + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setDeviceCredentialAllowed(true) + .build() + } + } + + private fun isBiometricHardWareAvailable(): Boolean { + // authentication occurs only when this is true and device is truly capable + var result = false + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when (biometricManager?.canAuthenticate( + DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK + )) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false + } + } else { + @Suppress("DEPRECATION") + when (biometricManager?.canAuthenticate()) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false + } + } + + return result + } + + // checks if device is secured i.e has at least some type of lock + fun deviceHasPasswordPinLock(context: Context?): Boolean { + val keyMgr = + context?.getSystemService(AppCompatActivity.KEYGUARD_SERVICE) as? KeyguardManager + return keyMgr?.isKeyguardSecure ?: false + } + + // function to start authentication in any fragment or activity + fun startBiometricAuthentication(activity: Activity, title: Int, setDeviceCred: Boolean) { + initializeBiometrics(activity) + authCallback = activity as? BiometricCallback + if (isBiometricHardWareAvailable()) { + authCallback = activity as? BiometricCallback + authenticationDialog(activity, title, setDeviceCred) + promptInfo?.let { biometricPrompt?.authenticate(it) } + } else { + if (deviceHasPasswordPinLock(activity)) { + authCallback = activity as? BiometricCallback + authenticationDialog(activity, R.string.password_pin_authentication_title, true) + promptInfo?.let { biometricPrompt?.authenticate(it) } + + } else { + showToast(R.string.biometric_unsupported) + } + } + } + + fun isAuthEnabled(ctx: Context):Boolean { + return ctx.let { + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(getString(ctx, R.string.biometric_key), false) + } + } + + interface BiometricCallback { + fun onAuthenticationSuccess() + fun onAuthenticationError() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt index 9e8cc1d4..d8373165 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.utils import android.net.Uri -import com.google.android.exoplayer2.util.MimeTypes +import androidx.media3.common.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.media.RemoteMediaClient @@ -55,7 +55,11 @@ object CastHelper { val builder = MediaInfo.Builder(link.url) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) - .setContentType(if (link.isM3u8) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4) + .setContentType(when(link.type) { + ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 + ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD + else -> MimeTypes.VIDEO_MP4 + }) .setMetadata(movieMetadata) .setMediaTracks(tracks) data?.let { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index e1cedd39..b5192aae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -5,8 +5,13 @@ import android.content.SharedPreferences import androidx.preference.PreferenceManager import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import kotlin.reflect.KClass +import kotlin.reflect.KProperty const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -18,8 +23,68 @@ const val USER_PROVIDER_API = "user_custom_sites" const val PREFERENCES_NAME = "rebuild_preference" +// TODO degelgate by value for get & set + +class PreferenceDelegate( + val key: String, val default: T //, private val klass: KClass +) { + private val klass: KClass = default::class + // simple cache to make it not get the key every time it is accessed, however this requires + // that ONLY this changes the key + private var cache: T? = null + + operator fun getValue(self: Any?, property: KProperty<*>) = + cache ?: getKeyClass(key, klass.java).also { newCache -> cache = newCache } ?: default + + operator fun setValue( + self: Any?, + property: KProperty<*>, + t: T? + ) { + cache = t + if (t == null) { + removeKey(key) + } else { + setKeyClass(key, t) + } + } +} + +/** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ +data class Editor( + val editor : SharedPreferences.Editor +) { + /** Always remember to call apply after */ + fun setKeyRaw(path: String, value: T) { + @Suppress("UNCHECKED_CAST") + if (isStringSet(value)) { + editor.putStringSet(path, value as Set) + } else { + when (value) { + is Boolean -> editor.putBoolean(path, value) + is Int -> editor.putInt(path, value) + is String -> editor.putString(path, value) + is Float -> editor.putFloat(path, value) + is Long -> editor.putLong(path, value) + } + } + } + + private fun isStringSet(value: Any?) : Boolean { + if (value is Set<*>) { + return value.filterIsInstance().size == value.size + } + return false + } + + fun apply() { + editor.apply() + System.gc() + } +} + object DataStore { - val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) + val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() private fun getPreferences(context: Context): SharedPreferences { @@ -34,22 +99,10 @@ object DataStore { return "${folder}/${path}" } - fun Context.setKeyRaw(path: String, value: T, isEditingAppSettings: Boolean = false) { - try { - val editor: SharedPreferences.Editor = - if (isEditingAppSettings) getDefaultSharedPrefs().edit() else getSharedPrefs().edit() - when (value) { - is Boolean -> editor.putBoolean(path, value) - is Int -> editor.putInt(path, value) - is String -> editor.putString(path, value) - is Float -> editor.putFloat(path, value) - is Long -> editor.putLong(path, value) - (value as? Set != null) -> editor.putStringSet(path, value as Set) - } - editor.apply() - } catch (e: Exception) { - logError(e) - } + fun editor(context : Context, isEditingAppSettings: Boolean = false) : Editor { + val editor: SharedPreferences.Editor = + if (isEditingAppSettings) context.getDefaultSharedPrefs().edit() else context.getSharedPrefs().edit() + return Editor(editor) } fun Context.getDefaultSharedPrefs(): SharedPreferences { @@ -87,7 +140,7 @@ object DataStore { } fun Context.removeKeys(folder: String): Int { - val keys = getKeys(folder) + val keys = getKeys("$folder/") keys.forEach { value -> removeKey(value) } @@ -104,6 +157,15 @@ object DataStore { } } + fun Context.getKey(path: String, valueType: Class): T? { + try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + return json.toKotlinObject(valueType) + } catch (e: Exception) { + return null + } + } + fun Context.setKey(folder: String, path: String, value: T) { setKey(getFolderName(folder, path), value) } @@ -112,6 +174,10 @@ object DataStore { return mapper.readValue(this, T::class.java) } + fun String.toKotlinObject(valueType: Class): T { + return mapper.readValue(this, valueType) + } + // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR inline fun Context.getKey(path: String, defVal: T?): T? { try { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 281c9c44..2fa5f6a3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,25 +1,42 @@ package com.lagradost.cloudstream3.utils +import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.capitalize +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.ui.result.UiImage import com.lagradost.cloudstream3.ui.result.VideoWatchState +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import kotlin.reflect.KClass +import kotlin.reflect.KProperty const val VIDEO_POS_DUR = "video_pos_dur" const val VIDEO_WATCH_STATE = "video_watch_state" const val RESULT_WATCH_STATE = "result_watch_state" const val RESULT_WATCH_STATE_DATA = "result_watch_state_data" +const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data" +const val RESULT_FAVORITES_STATE_DATA = "result_favorites_state_data" const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching" const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated" @@ -27,7 +44,153 @@ const val RESULT_EPISODE = "result_episode" const val RESULT_SEASON = "result_season" const val RESULT_DUB = "result_dub" + +class UserPreferenceDelegate( + private val key: String, private val default: T //, private val klass: KClass +) { + private val klass: KClass = default::class + private val realKey get() = "${DataStoreHelper.currentAccount}/$key" + operator fun getValue(self: Any?, property: KProperty<*>) = + AcraApplication.getKeyClass(realKey, klass.java) ?: default + + operator fun setValue( + self: Any?, + property: KProperty<*>, + t: T? + ) { + if (t == null) { + removeKey(realKey) + } else { + AcraApplication.setKeyClass(realKey, t) + } + } +} + object DataStoreHelper { + // be aware, don't change the index of these as Account uses the index for the art + val profileImages = arrayOf( + R.drawable.profile_bg_dark_blue, + R.drawable.profile_bg_blue, + R.drawable.profile_bg_orange, + R.drawable.profile_bg_pink, + R.drawable.profile_bg_purple, + R.drawable.profile_bg_red, + R.drawable.profile_bg_teal + ) + + private var searchPreferenceProvidersStrings : List by UserPreferenceDelegate( + /** java moment right here, as listOf()::class.java != List(0) { "" }::class.java */ + "search_pref_providers", List(0) { "" } + ) + + private fun serializeTv(data : List) : List = data.map { it.name } + + private fun deserializeTv(data : List) : List { + return data.mapNotNull { listName -> + TvType.values().firstOrNull { it.name == listName } + } + } + + var searchPreferenceProviders : List + get() { + val ret = searchPreferenceProvidersStrings + return ret.ifEmpty { + context?.filterProviderByPreferredMedia()?.map { it.name } ?: emptyList() + } + } set(value) { + searchPreferenceProvidersStrings = value + } + + private var searchPreferenceTagsStrings : List by UserPreferenceDelegate("search_pref_tags", listOf(TvType.Movie, TvType.TvSeries).map { it.name }) + var searchPreferenceTags : List + get() = deserializeTv(searchPreferenceTagsStrings) + set(value) { + searchPreferenceTagsStrings = serializeTv(value) + } + + + private var homePreferenceStrings : List by UserPreferenceDelegate("home_pref_homepage", listOf(TvType.Movie, TvType.TvSeries).map { it.name }) + var homePreference : List + get() = deserializeTv(homePreferenceStrings) + set(value) { + homePreferenceStrings = serializeTv(value) + } + + var homeBookmarkedList : IntArray by UserPreferenceDelegate("home_bookmarked_last_list", IntArray(0)) + var playBackSpeed : Float by UserPreferenceDelegate("playback_speed", 1.0f) + var resizeMode : Int by UserPreferenceDelegate("resize_mode", 0) + var librarySortingMode : Int by UserPreferenceDelegate("library_sorting_mode", ListSorting.AlphabeticalA.ordinal) + + data class Account( + @JsonProperty("keyIndex") + val keyIndex: Int, + @JsonProperty("name") + val name: String, + @JsonProperty("customImage") + val customImage: String? = null, + @JsonProperty("defaultImageIndex") + val defaultImageIndex: Int, + @JsonProperty("lockPin") + val lockPin: String? = null, + ) { + val image: UiImage + get() = customImage?.let { UiImage.Image(it) } ?: UiImage.Drawable( + profileImages.getOrNull(defaultImageIndex) ?: profileImages.first() + ) + } + + const val TAG = "data_store_helper" + var accounts by PreferenceDelegate("$TAG/account", arrayOf()) + var selectedKeyIndex by PreferenceDelegate("$TAG/account_key_index", 0) + val currentAccount: String get() = selectedKeyIndex.toString() + + /** + * Get or set the current account homepage. + * Setting this does not automatically reload the homepage. + */ + var currentHomePage: String? + get() = getKey("$currentAccount/$USER_SELECTED_HOMEPAGE_API") + set(value) { + val key = "$currentAccount/$USER_SELECTED_HOMEPAGE_API" + if (value == null) { + removeKey(key) + } else { + setKey(key, value) + } + } + + fun setAccount(account: Account) { + val homepage = currentHomePage + + selectedKeyIndex = account.keyIndex + showToast(context?.getString(R.string.logged_account, account.name) ?: account.name) + MainActivity.bookmarksUpdatedEvent(true) + MainActivity.reloadLibraryEvent(true) + val oldAccount = accounts.find { it.keyIndex == account.keyIndex } + if (oldAccount != null && currentHomePage != homepage) { + // This is not a new account, and the homepage has changed, reload it + MainActivity.reloadHomeEvent(true) + } + } + + fun getDefaultAccount(context: Context): Account { + return accounts.let { currentAccounts -> + currentAccounts.getOrNull(currentAccounts.indexOfFirst { it.keyIndex == 0 }) ?: Account( + keyIndex = 0, + name = context.getString(R.string.default_account), + defaultImageIndex = 0 + ) + } + } + + fun getAccounts(context: Context): List { + return accounts.toMutableList().apply { + val item = getDefaultAccount(context) + remove(item) + add(0, item) + } + } + data class PosDur( @JsonProperty("position") val position: Long, @JsonProperty("duration") val duration: Long @@ -42,19 +205,77 @@ object DataStoreHelper { return this } - data class BookmarkedData( + fun Int.toYear() : Date = GregorianCalendar.getInstance().also { it.set(Calendar.YEAR, this) }.time + + /** + * Used to display notifications on new episodes and posters in library. + **/ + abstract class LibrarySearchResponse( @JsonProperty("id") override var id: Int?, - @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, - @JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long, + @JsonProperty("latestUpdatedTime") open val latestUpdatedTime: Long, @JsonProperty("name") override val name: String, @JsonProperty("url") override val url: String, @JsonProperty("apiName") override val apiName: String, - @JsonProperty("type") override var type: TvType? = null, + @JsonProperty("type") override var type: TvType?, @JsonProperty("posterUrl") override var posterUrl: String?, - @JsonProperty("year") val year: Int?, - @JsonProperty("quality") override var quality: SearchQuality? = null, - @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, - ) : SearchResponse { + @JsonProperty("year") open val year: Int?, + @JsonProperty("syncData") open val syncData: Map?, + @JsonProperty("quality") override var quality: SearchQuality?, + @JsonProperty("posterHeaders") override var posterHeaders: Map?, + @JsonProperty("plot") open val plot : String? = null, + @JsonProperty("rating") open val rating : Int? = null, + @JsonProperty("tags") open val tags : List? = null, + ) : SearchResponse + + data class SubscribedData( + @JsonProperty("subscribedTime") val subscribedTime: Long, + @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map, + override var id: Int?, + override val latestUpdatedTime: Long, + override val name: String, + override val url: String, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override val year: Int?, + override val syncData: Map? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override val plot: String? = null, + override val rating: Int? = null, + override val tags: List? = null, + ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot,rating,tags) { + fun toLibraryItem(): SyncAPI.LibraryItem? { + return SyncAPI.LibraryItem( + name, + url, + id?.toString() ?: return null, + null, + null, + null, + latestUpdatedTime, + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags + ) + } + } + + data class BookmarkedData( + @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, + override var id: Int?, + override val latestUpdatedTime: Long, + override val name: String, + override val url: String, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override val year: Int?, + override val syncData: Map? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override val plot: String? = null, + override val rating: Int? = null, + override val tags: List? = null, + ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot) { fun toLibraryItem(id: String): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( name, @@ -63,8 +284,39 @@ object DataStoreHelper { null, null, null, + latestUpdatedTime, + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags + ) + } + } + + data class FavoritesData( + @JsonProperty("favoritesTime") val favoritesTime: Long, + override var id: Int?, + override val latestUpdatedTime: Long, + override val name: String, + override val url: String, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override val year: Int?, + override val syncData: Map? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override val plot: String? = null, + override val rating: Int? = null, + override val tags: List? = null, + ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders,plot) { + fun toLibraryItem(): SyncAPI.LibraryItem? { + return SyncAPI.LibraryItem( + name, + url, + id?.toString() ?: return null, null, - apiName, type, posterUrl, posterHeaders, quality, this.id + null, + null, + latestUpdatedTime, + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags ) } } @@ -75,9 +327,7 @@ object DataStoreHelper { @JsonProperty("apiName") override val apiName: String, @JsonProperty("type") override var type: TvType? = null, @JsonProperty("posterUrl") override var posterUrl: String?, - @JsonProperty("watchPos") val watchPos: PosDur?, - @JsonProperty("id") override var id: Int?, @JsonProperty("parentId") val parentId: Int?, @JsonProperty("episode") val episode: Int?, @@ -90,7 +340,6 @@ object DataStoreHelper { /** * A datastore wide account for future implementations of a multiple account system **/ - private var currentAccount: String = "0" //TODO ACCOUNT IMPLEMENTATION fun getAllWatchStateIds(): List? { val folder = "$currentAccount/$RESULT_WATCH_STATE" @@ -104,11 +353,11 @@ object DataStoreHelper { removeKeys(folder) } - fun deleteAllBookmarkedData() { - val folder1 = "$currentAccount/$RESULT_WATCH_STATE" - val folder2 = "$currentAccount/$RESULT_WATCH_STATE_DATA" - removeKeys(folder1) - removeKeys(folder2) + fun deleteBookmarkedData(id: Int?) { + if (id == null) return + AccountManager.localListApi.requireLibraryRefresh = true + removeKey("$currentAccount/$RESULT_WATCH_STATE", id.toString()) + removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } fun getAllResumeStateIds(): List? { @@ -204,6 +453,70 @@ object DataStoreHelper { return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } + fun getAllBookmarkedData(): List { + return getKeys("$currentAccount/$RESULT_WATCH_STATE_DATA")?.mapNotNull { + getKey(it) + } ?: emptyList() + } + + fun getAllSubscriptions(): List { + return getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.mapNotNull { + getKey(it) + } ?: emptyList() + } + + fun removeSubscribedData(id: Int?) { + if (id == null) return + AccountManager.localListApi.requireLibraryRefresh = true + removeKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) + } + + /** + * Set new seen episodes and update time + **/ + fun updateSubscribedData(id: Int?, data: SubscribedData?, episodeResponse: EpisodeResponse?) { + if (id == null || data == null || episodeResponse == null) return + val newData = data.copy( + latestUpdatedTime = unixTimeMS, + lastSeenEpisodeCount = episodeResponse.getLatestEpisodes() + ) + setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), newData) + } + + fun setSubscribedData(id: Int?, data: SubscribedData) { + if (id == null) return + setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), data) + AccountManager.localListApi.requireLibraryRefresh = true + } + + fun getSubscribedData(id: Int?): SubscribedData? { + if (id == null) return null + return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) + } + + fun getAllFavorites(): List { + return getKeys("$currentAccount/$RESULT_FAVORITES_STATE_DATA")?.mapNotNull { + getKey(it) + } ?: emptyList() + } + + fun removeFavoritesData(id: Int?) { + if (id == null) return + AccountManager.localListApi.requireLibraryRefresh = true + removeKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) + } + + fun setFavoritesData(id: Int?, data: FavoritesData) { + if (id == null) return + setKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString(), data) + AccountManager.localListApi.requireLibraryRefresh = true + } + + fun getFavoritesData(id: Int?): FavoritesData? { + if (id == null) return null + return getKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) + } + fun setViewPos(id: Int?, pos: Long, dur: Long) { if (id == null) return if (dur < 30_000) return // too short @@ -242,12 +555,10 @@ object DataStoreHelper { fun setResultWatchState(id: Int?, status: Int) { if (id == null) return - val folder = "$currentAccount/$RESULT_WATCH_STATE" if (status == WatchType.NONE.internalId) { - removeKey(folder, id.toString()) - removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) + deleteBookmarkedData(id) } else { - setKey(folder, id.toString(), status) + setKey("$currentAccount/$RESULT_WATCH_STATE", id.toString(), status) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index c1eb649b..c92da214 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage import kotlinx.coroutines.delay const val DOWNLOAD_CHECK = "DownloadCheck" @@ -25,28 +26,32 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo override suspend fun doWork(): Result { val key = workerParams.inputData.getString("key") try { - println("KEY $key") if (key == DOWNLOAD_CHECK) { - downloadCheck(applicationContext, ::handleNotification)?.let { - awaitDownload(it) - } + downloadCheck(applicationContext, ::handleNotification) } else if (key != null) { - val info = applicationContext.getKey(WORK_KEY_INFO, key) + val info = + applicationContext.getKey(WORK_KEY_INFO, key) val pkg = - applicationContext.getKey(WORK_KEY_PACKAGE, key) - if (info != null) { - downloadEpisode( - applicationContext, - info.source, - info.folder, - info.ep, - info.links, - ::handleNotification + applicationContext.getKey( + WORK_KEY_PACKAGE, + key ) - awaitDownload(info.ep.id) + + if (info != null) { + getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg -> + downloadFromResume(applicationContext, dpkg, ::handleNotification) + } ?: run { + downloadEpisode( + applicationContext, + info.source, + info.folder, + info.ep, + info.links, + ::handleNotification + ) + } } else if (pkg != null) { downloadFromResume(applicationContext, pkg, ::handleNotification) - awaitDownload(pkg.item.ep.id) } removeKeys(key) } @@ -73,6 +78,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { isDone = true } + else -> Unit } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt index 26f83d1e..a0dfe734 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -3,16 +3,24 @@ package com.lagradost.cloudstream3.utils class Event { private val observers = mutableSetOf<(T) -> Unit>() + val size: Int get() = observers.size + operator fun plusAssign(observer: (T) -> Unit) { - observers.add(observer) + synchronized(observers) { + observers.add(observer) + } } operator fun minusAssign(observer: (T) -> Unit) { - observers.remove(observer) + synchronized(observers) { + observers.remove(observer) + } } operator fun invoke(value: T) { - for (observer in observers) - observer(value) + synchronized(observers) { + for (observer in observers) + observer(value) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt deleted file mode 100644 index 1ad3639b..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ /dev/null @@ -1,488 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.net.Uri -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.extractors.* -import kotlinx.coroutines.delay -import org.jsoup.Jsoup -import kotlin.collections.MutableList - -/** - * For use in the ConcatenatingMediaSource. - * If features are missing (headers), please report and we can add it. - * @param durationUs use Long.toUs() for easier input - * */ -data class PlayListItem( - val url: String, - val durationUs: Long, -) - -/** - * Converts Seconds to MicroSeconds, multiplication by 1_000_000 - * */ -fun Long.toUs(): Long { - return this * 1_000_000 -} - -/** - * If your site has an unorthodox m3u8-like system where there are multiple smaller videos concatenated - * use this. - * */ -data class ExtractorLinkPlayList( - override val source: String, - override val name: String, - val playlist: List, - override val referer: String, - override val quality: Int, - override val isM3u8: Boolean = false, - override val headers: Map = mapOf(), - /** Used for getExtractorVerifierJob() */ - override val extractorData: String? = null, -) : ExtractorLink( - source, - name, - // Blank as un-used - "", - referer, - quality, - isM3u8, - headers, - extractorData -) - - -open class ExtractorLink( - open val source: String, - open val name: String, - override val url: String, - override val referer: String, - open val quality: Int, - open val isM3u8: Boolean = false, - override val headers: Map = mapOf(), - /** Used for getExtractorVerifierJob() */ - open val extractorData: String? = null, -) : VideoDownloadManager.IDownloadableMinimum { - override fun toString(): String { - return "ExtractorLink(name=$name, url=$url, referer=$referer, isM3u8=$isM3u8)" - } -} - -data class ExtractorUri( - val uri: Uri, - val name: String, - - val basePath: String? = null, - val relativePath: String? = null, - val displayName: String? = null, - - val id: Int? = null, - val parentId: Int? = null, - val episode: Int? = null, - val season: Int? = null, - val headerName: String? = null, - val tvType: TvType? = null, -) - -data class ExtractorSubtitleLink( - val name: String, - override val url: String, - override val referer: String, - override val headers: Map = mapOf() -) : VideoDownloadManager.IDownloadableMinimum - -/** - * Removes https:// and www. - * To match urls regardless of schema, perhaps Uri() can be used? - */ -val schemaStripRegex = Regex("""^(https:|)//(www\.|)""") - -enum class Qualities(var value: Int) { - Unknown(400), - P144(144), // 144p - P240(240), // 240p - P360(360), // 360p - P480(480), // 480p - P720(720), // 720p - P1080(1080), // 1080p - P1440(1440), // 1440p - P2160(2160); // 4k or 2160p - - companion object { - fun getStringByInt(qual: Int?): String { - return when (qual) { - 0 -> "Auto" - Unknown.value -> "" - P2160.value -> "4K" - null -> "" - else -> "${qual}p" - } - } - } -} - -fun getQualityFromName(qualityName: String?): Int { - if (qualityName == null) - return Qualities.Unknown.value - - val match = qualityName.lowercase().replace("p", "").trim() - return when (match) { - "4k" -> Qualities.P2160 - else -> null - }?.value ?: match.toIntOrNull() ?: Qualities.Unknown.value -} - -private val packedRegex = Regex("""eval\(function\(p,a,c,k,e,.*\)\)""") -fun getPacked(string: String): String? { - return packedRegex.find(string)?.value -} - -fun getAndUnpack(string: String): String { - val packedText = getPacked(string) - return JsUnpacker(packedText).unpack() ?: string -} - -suspend fun unshortenLinkSafe(url: String): String { - return try { - if (ShortLink.isShortLink(url)) - ShortLink.unshorten(url) - else url - } catch (e: Exception) { - logError(e) - url - } -} - -suspend fun loadExtractor( - url: String, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit -): Boolean { - return loadExtractor( - url = url, - referer = null, - subtitleCallback = subtitleCallback, - callback = callback - ) -} - -/** - * Tries to load the appropriate extractor based on link, returns true if any extractor is loaded. - * */ -suspend fun loadExtractor( - url: String, - referer: String? = null, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit -): Boolean { - val currentUrl = unshortenLinkSafe(url) - val compareUrl = currentUrl.lowercase().replace(schemaStripRegex, "") - for (extractor in extractorApis) { - if (compareUrl.startsWith(extractor.mainUrl.replace(schemaStripRegex, ""))) { - extractor.getSafeUrl(currentUrl, referer, subtitleCallback, callback) - return true - } - } - - return false -} - -val extractorApis: MutableList = arrayListOf( - //AllProvider(), - WcoStream(), - Vidstreamz(), - Vizcloud(), - Vizcloud2(), - VizcloudOnline(), - VizcloudXyz(), - VizcloudLive(), - VizcloudInfo(), - MwvnVizcloudInfo(), - VizcloudDigital(), - VizcloudCloud(), - VizcloudSite(), - VideoVard(), - VideovardSX(), - Mp4Upload(), - StreamTape(), - StreamTapeNet(), - ShaveTape(), - - //mixdrop extractors - MixDropBz(), - MixDropCh(), - MixDropTo(), - - MixDrop(), - - Mcloud(), - XStreamCdn(), - - StreamSB(), - StreamSB1(), - StreamSB2(), - StreamSB3(), - StreamSB4(), - StreamSB5(), - StreamSB6(), - StreamSB7(), - StreamSB8(), - StreamSB9(), - StreamSB10(), - SBfull(), - // Streamhub(), cause Streamhub2() works - Streamhub2(), - Ssbstream(), - Sbthe(), - Vidgomunime(), - Sbflix(), - Streamsss(), - Sbspeed(), - - Fastream(), - - FEmbed(), - FeHD(), - Fplayer(), - DBfilm(), - Luxubu(), - LayarKaca(), - Rasacintaku(), - FEnet(), - Kotakajair(), - Cdnplayer(), - // WatchSB(), 'cause StreamSB.kt works - Uqload(), - Uqload1(), - Evoload(), - Evoload1(), - VoeExtractor(), - UpstreamExtractor(), - - Tomatomatela(), - TomatomatelalClub(), - Cinestart(), - OkRu(), - OkRuHttps(), - Okrulink(), - - // dood extractors - DoodCxExtractor(), - DoodPmExtractor(), - DoodToExtractor(), - DoodSoExtractor(), - DoodLaExtractor(), - DoodWsExtractor(), - DoodShExtractor(), - DoodWatchExtractor(), - DoodWfExtractor(), - - AsianLoad(), - - // GenericM3U8(), - Jawcloud(), - Zplayer(), - ZplayerV2(), - Upstream(), - - Maxstream(), - Tantifilm(), - Userload(), - Supervideo(), - GuardareStream(), - CineGrabber(), - Vanfem(), - - // StreamSB.kt works - // SBPlay(), - // SBPlay1(), - // SBPlay2(), - - PlayerVoxzer(), - - BullStream(), - GMPlayer(), - - Blogger(), - Solidfiles(), - YourUpload(), - - Hxfile(), - KotakAnimeid(), - Neonime8n(), - Neonime7n(), - Yufiles(), - Aico(), - - JWPlayer(), - Meownime(), - DesuArcg(), - DesuOdchan(), - DesuOdvip(), - DesuDrive(), - - Filesim(), - FileMoon(), - Linkbox(), - Acefile(), - SpeedoStream(), - SpeedoStream1(), - Zorofile(), - Embedgram(), - Mvidoo(), - Streamplay(), - Vidmoly(), - Vidmolyme(), - Voe(), - Moviehab(), - MoviehabNet(), - Jeniusplay(), - - Gdriveplayerapi(), - Gdriveplayerapp(), - Gdriveplayerfun(), - Gdriveplayerio(), - Gdriveplayerme(), - Gdriveplayerbiz(), - Gdriveplayerorg(), - Gdriveplayerus(), - Gdriveplayerco(), - Gdriveplayer(), - DatabaseGdrive(), - DatabaseGdrive2(), - - YoutubeExtractor(), - YoutubeShortLinkExtractor(), - YoutubeMobileExtractor(), - YoutubeNoCookieExtractor(), - Streamlare(), - VidSrcExtractor(), - VidSrcExtractor2(), - PlayLtXyz(), - AStreamHub(), - - Cda(), - Dailymotion(), - ByteShare(), -) - - -fun getExtractorApiFromName(name: String): ExtractorApi { - for (api in extractorApis) { - if (api.name == name) return api - } - return extractorApis[0] -} - -fun requireReferer(name: String): Boolean { - return getExtractorApiFromName(name).requiresReferer -} - -fun httpsify(url: String): String { - return if (url.startsWith("//")) "https:$url" else url -} - -suspend fun getPostForm(requestUrl: String, html: String): String? { - val document = Jsoup.parse(html) - val inputs = document.select("Form > input") - if (inputs.size < 4) return null - var op: String? = null - var id: String? = null - var mode: String? = null - var hash: String? = null - - for (input in inputs) { - val value = input.attr("value") ?: continue - when (input.attr("name")) { - "op" -> op = value - "id" -> id = value - "mode" -> mode = value - "hash" -> hash = value - else -> Unit - } - } - if (op == null || id == null || mode == null || hash == null) { - return null - } - delay(5000) // ye this is needed, wont work with 0 delay - - return app.post( - requestUrl, - headers = mapOf( - "content-type" to "application/x-www-form-urlencoded", - "referer" to requestUrl, - "user-agent" to USER_AGENT, - "accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - ), - data = mapOf("op" to op, "id" to id, "mode" to mode, "hash" to hash) - ).text -} - -fun ExtractorApi.fixUrl(url: String): String { - if (url.startsWith("http") || - // Do not fix JSON objects when passed as urls. - url.startsWith("{\"") - ) { - return url - } - if (url.isEmpty()) { - return "" - } - - val startsWithNoHttp = url.startsWith("//") - if (startsWithNoHttp) { - return "https:$url" - } else { - if (url.startsWith('/')) { - return mainUrl + url - } - return "$mainUrl/$url" - } -} - -abstract class ExtractorApi { - abstract val name: String - abstract val mainUrl: String - abstract val requiresReferer: Boolean - - /** Determines which plugin a given extractor is from */ - var sourcePlugin: String? = null - - //suspend fun getSafeUrl(url: String, referer: String? = null): List? { - // return suspendSafeApiCall { getUrl(url, referer) } - //} - - // this is the new extractorapi, override to add subtitles and stuff - open suspend fun getUrl( - url: String, - referer: String? = null, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - getUrl(url, referer)?.forEach(callback) - } - - suspend fun getSafeUrl( - url: String, - referer: String? = null, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - try { - getUrl(url, referer, subtitleCallback, callback) - } catch (e: Exception) { - logError(e) - } - } - - /** - * Will throw errors, use getSafeUrl if you don't want to handle the exception yourself - */ - open suspend fun getUrl(url: String, referer: String? = null): List? { - return emptyList() - } - - open fun getExtractorUrl(id: String): String { - return id - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt index 4b0ee890..38d3fe9e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt @@ -8,6 +8,7 @@ import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.request.RequestOptions @@ -27,6 +28,10 @@ class GlideModule : AppGlideModule() { RequestOptions() .diskCacheStrategy(DiskCacheStrategy.ALL) .signature(ObjectKey(System.currentTimeMillis().toShort())) + }.setDiskCache { + // Possible to make this a setting in the future. + val memoryCacheSizeBytes: Long = 1024 * 1024 * 100 // 100mb + InternalCacheDiskCacheFactory(context, memoryCacheSizeBytes).build() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt deleted file mode 100644 index b4922945..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.lagradost.cloudstream3.utils - -interface IOnBackPressed { - fun onBackPressed(): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 8b516e8c..59f534ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -23,7 +23,8 @@ import okio.buffer import okio.sink import java.io.File import android.text.TextUtils -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -31,26 +32,26 @@ import java.io.InputStreamReader class InAppUpdater { companion object { - const val GITHUB_USER_NAME = "recloudstream" - const val GITHUB_REPO = "cloudstream" + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" - const val LOG_TAG = "InAppUpdater" + private const val LOG_TAG = "InAppUpdater" // === IN APP UPDATER === data class GithubAsset( @JsonProperty("name") val name: String, @JsonProperty("size") val size: Int, // Size bytes - @JsonProperty("browser_download_url") val browser_download_url: String, // download link - @JsonProperty("content_type") val content_type: String, // application/vnd.android.package-archive + @JsonProperty("browser_download_url") val browserDownloadUrl: String, // download link + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive ) data class GithubRelease( - @JsonProperty("tag_name") val tag_name: String, // Version code + @JsonProperty("tag_name") val tagName: String, // Version code @JsonProperty("body") val body: String, // Desc @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val target_commitish: String, // branch + @JsonProperty("target_commitish") val targetCommitish: String, // branch @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val node_id: String //Node Id + @JsonProperty("node_id") val nodeId: String //Node Id ) data class GithubObject( @@ -60,7 +61,7 @@ class InAppUpdater { ) data class GithubTag( - @JsonProperty("object") val github_object: GithubObject, + @JsonProperty("object") val githubObject: GithubObject, ) data class Update( @@ -109,18 +110,19 @@ class InAppUpdater { releases.sortedWith(compareBy { versionRegex.find(it.name)?.groupValues?.get(2) }).toList().lastOrNull()*/ - val found = + val foundList = response.filter { rel -> !rel.prerelease }.sortedWith(compareBy { release -> - release.assets.filter { it.content_type == "application/vnd.android.package-archive" } - .getOrNull(0)?.name?.let { it1 -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> versionRegex.find( it1 - )?.groupValues?.get(2) + )?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } } - }).toList().lastOrNull() - + }).toList() + val found = foundList.lastOrNull() val foundAsset = found?.assets?.getOrNull(0) val currentVersion = packageName?.let { packageManager.getPackageInfo( @@ -132,7 +134,7 @@ class InAppUpdater { foundAsset?.name?.let { assetName -> val foundVersion = versionRegex.find(assetName) val shouldUpdate = - if (foundAsset.browser_download_url != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> + if (foundAsset.browserDownloadUrl != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> versionRegexLocal.find(versionName)?.groupValues?.let { it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } @@ -144,10 +146,10 @@ class InAppUpdater { return if (foundVersion != null) { Update( shouldUpdate, - foundAsset.browser_download_url, + foundAsset.browserDownloadUrl, foundVersion.groupValues[2], found.body, - found.node_id + found.nodeId ) } else { Update(false, null, null, null, null) @@ -166,33 +168,33 @@ class InAppUpdater { val found = response.lastOrNull { rel -> - rel.prerelease || rel.tag_name == "pre-release" + rel.prerelease || rel.tagName == "pre-release" } val foundAsset = found?.assets?.filter { it -> - it.content_type == "application/vnd.android.package-archive" + it.contentType == "application/vnd.android.package-archive" }?.getOrNull(0) val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) - Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.github_object.sha.take(7)}") + Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.githubObject.sha.take(7)}") val shouldUpdate = (getString(R.string.commit_hash) .trim { c -> c.isWhitespace() } .take(7) != - tagResponse.github_object.sha + tagResponse.githubObject.sha .trim { c -> c.isWhitespace() } .take(7)) return if (foundAsset != null) { Update( shouldUpdate, - foundAsset.browser_download_url, - tagResponse.github_object.sha, + foundAsset.browserDownloadUrl, + tagResponse.githubObject.sha.take(10), found.body, - found.node_id + found.nodeId ) } else { Update(false, null, null, null, null) @@ -212,7 +214,7 @@ class InAppUpdater { this.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { - it.deleteOnExit() + deleteFileOnExit(it) } val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") @@ -292,7 +294,13 @@ class InAppUpdater { update.updateVersion ) ) - builder.setMessage("${update.changelog}") + + val logRegex = Regex("\\[(.*?)\\]\\((.*?)\\)") + val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> + matchResult.groupValues[1] + } // Sanitized because it looks cluttered + + builder.setMessage(sanitizedChangelog) val context = this builder.apply { @@ -300,7 +308,7 @@ class InAppUpdater { // Forcefully start any delayed installations if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton - showToast(context, R.string.download_started, Toast.LENGTH_LONG) + showToast(R.string.download_started, Toast.LENGTH_LONG) // Check if the setting hasn't been changed if (settingsManager.getInt( @@ -335,7 +343,6 @@ class InAppUpdater { if (!downloadUpdate(update.updateURL)) runOnUiThread { showToast( - context, R.string.download_failed, Toast.LENGTH_LONG ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt deleted file mode 100644 index 6c5117b4..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ /dev/null @@ -1,272 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import kotlinx.coroutines.runBlocking -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.math.pow - - -class M3u8Helper { - companion object { - private val generator = M3u8Helper() - suspend fun generateM3u8( - source: String, - streamUrl: String, - referer: String, - quality: Int? = null, - headers: Map = mapOf(), - name: String = source - ): List { - return generator.m3u8Generation( - M3u8Stream( - streamUrl = streamUrl, - quality = quality, - headers = headers, - ), null - ) - .map { stream -> - ExtractorLink( - source, - name = name, - stream.streamUrl, - referer, - stream.quality ?: Qualities.Unknown.value, - true, - stream.headers, - ) - } - } - } - - private val ENCRYPTION_DETECTION_REGEX = Regex("#EXT-X-KEY:METHOD=([^,]+),") - private val ENCRYPTION_URL_IV_REGEX = - Regex("#EXT-X-KEY:METHOD=([^,]+),URI=\"([^\"]+)\"(?:,IV=(.*))?") - private val QUALITY_REGEX = - Regex("""#EXT-X-STREAM-INF:(?:(?:.*?(?:RESOLUTION=\d+x(\d+)).*?\s+(.*))|(?:.*?\s+(.*)))""") - private val TS_EXTENSION_REGEX = - Regex("""(.*\.ts.*|.*\.jpg.*)""") //.jpg here 'case vizcloud uses .jpg instead of .ts - - private fun absoluteExtensionDetermination(url: String): String? { - val split = url.split("/") - val gg: String = split[split.size - 1].split("?")[0] - return if (gg.contains(".")) { - gg.split(".").ifEmpty { null }?.last() - } else null - } - - private fun toBytes16Big(n: Int): ByteArray { - return ByteArray(16) { - val fixed = n / 256.0.pow((15 - it)) - (maxOf(0, fixed.toInt()) % 256).toByte() - } - } - - private val defaultIvGen = sequence { - var initial = 1 - - while (true) { - yield(toBytes16Big(initial)) - ++initial - } - }.iterator() - - private fun getDecrypter( - secretKey: ByteArray, - data: ByteArray, - iv: ByteArray = "".toByteArray() - ): ByteArray { - val ivKey = if (iv.isEmpty()) defaultIvGen.next() else iv - val c = Cipher.getInstance("AES/CBC/PKCS5Padding") - val skSpec = SecretKeySpec(secretKey, "AES") - val ivSpec = IvParameterSpec(ivKey) - c.init(Cipher.DECRYPT_MODE, skSpec, ivSpec) - return c.doFinal(data) - } - - private fun isEncrypted(m3u8Data: String): Boolean { - val st = ENCRYPTION_DETECTION_REGEX.find(m3u8Data) - return st != null && (st.value.isNotEmpty() || st.destructured.component1() != "NONE") - } - - data class M3u8Stream( - val streamUrl: String, - val quality: Int? = null, - val headers: Map = mapOf() - ) - - private fun selectBest(qualities: List): M3u8Stream? { - val result = qualities.sortedBy { - if (it.quality != null && it.quality <= 1080) it.quality else 0 - }.filter { - listOf("m3u", "m3u8").contains(absoluteExtensionDetermination(it.streamUrl)) - } - return result.lastOrNull() - } - - private fun getParentLink(uri: String): String { - val split = uri.split("/").toMutableList() - split.removeLast() - return split.joinToString("/") - } - - private fun isNotCompleteUrl(url: String): Boolean { - return !url.contains("https://") && !url.contains("http://") - } - - suspend fun m3u8Generation(m3u8: M3u8Stream, returnThis: Boolean? = true): List { -// return listOf(m3u8) - val list = mutableListOf() - - val m3u8Parent = getParentLink(m3u8.streamUrl) - val response = app.get(m3u8.streamUrl, headers = m3u8.headers, verify = false).text - -// var hasAnyContent = false - for (match in QUALITY_REGEX.findAll(response)) { -// hasAnyContent = true - var (quality, m3u8Link, m3u8Link2) = match.destructured - if (m3u8Link.isEmpty()) m3u8Link = m3u8Link2 - if (absoluteExtensionDetermination(m3u8Link) == "m3u8") { - if (isNotCompleteUrl(m3u8Link)) { - m3u8Link = "$m3u8Parent/$m3u8Link" - } - if (quality.isEmpty()) { - println(m3u8.streamUrl) - } - list += m3u8Generation( - M3u8Stream( - m3u8Link, - quality.toIntOrNull(), - m3u8.headers - ), false - ) - } - list += M3u8Stream( - m3u8Link, - quality.toIntOrNull(), - m3u8.headers - ) - } - if (returnThis != false) { - list += M3u8Stream( - m3u8.streamUrl, - Qualities.Unknown.value, - m3u8.headers - ) - } - - return list - } - - - data class HlsDownloadData( - val bytes: ByteArray, - val currentIndex: Int, - val totalTs: Int, - val errored: Boolean = false - ) - - suspend fun hlsYield( - qualities: List, - startIndex: Int = 0 - ): Iterator { - if (qualities.isEmpty()) return listOf( - HlsDownloadData( - byteArrayOf(), - 1, - 1, - true - ) - ).iterator() - - var selected = selectBest(qualities) - if (selected == null) { - selected = qualities[0] - } - val headers = selected.headers - - val streams = qualities.map { m3u8Generation(it, false) }.flatten() - //val sslVerification = if (headers.containsKey("ssl_verification")) headers["ssl_verification"].toBoolean() else true - - val secondSelection = selectBest(streams.ifEmpty { listOf(selected) }) - if (secondSelection != null) { - val m3u8Response = - runBlocking { - app.get( - secondSelection.streamUrl, - headers = headers, - verify = false - ).text - } - - var encryptionUri: String? - var encryptionIv = byteArrayOf() - var encryptionData = byteArrayOf() - - val encryptionState = isEncrypted(m3u8Response) - - if (encryptionState) { - val match = - ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.destructured // its safe to assume that its not going to be null - encryptionUri = match.component2() - - if (isNotCompleteUrl(encryptionUri)) { - encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri" - } - - encryptionIv = match.component3().toByteArray() - val encryptionKeyResponse = - runBlocking { app.get(encryptionUri, headers = headers, verify = false) } - encryptionData = encryptionKeyResponse.body?.bytes() ?: byteArrayOf() - } - - val allTs = TS_EXTENSION_REGEX.findAll(m3u8Response) - val allTsList = allTs.toList() - val totalTs = allTsList.size - if (totalTs == 0) { - return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator() - } - var lastYield = 0 - - val relativeUrl = getParentLink(secondSelection.streamUrl) - var retries = 0 - val tsByteGen = sequence { - loop@ for ((index, ts) in allTs.withIndex()) { - val url = if ( - isNotCompleteUrl(ts.destructured.component1()) - ) "$relativeUrl/${ts.destructured.component1()}" else ts.destructured.component1() - val c = index + 1 + startIndex - - while (lastYield != c) { - try { - val tsResponse = - runBlocking { app.get(url, headers = headers, verify = false) } - var tsData = tsResponse.body?.bytes() ?: byteArrayOf() - - if (encryptionState) { - tsData = getDecrypter(encryptionData, tsData, encryptionIv) - yield(HlsDownloadData(tsData, c, totalTs)) - lastYield = c - break - } - yield(HlsDownloadData(tsData, c, totalTs)) - lastYield = c - } catch (e: Exception) { - logError(e) - if (retries == 3) { - yield(HlsDownloadData(byteArrayOf(), c, totalTs, true)) - break@loop - } - ++retries - Thread.sleep(2_000) - } - } - } - } - return tsByteGen.iterator() - } - return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator() - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt index bc81a5b9..4b3f02f1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -11,7 +11,6 @@ import android.os.Build import android.widget.Toast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.Coroutines.main import java.io.InputStream @@ -57,7 +56,7 @@ class ApkInstaller(private val service: PackageInstallerService) { PackageInstaller.STATUS_FAILURE )) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT) + val userAction = intent.getSafeParcelableExtra(Intent.EXTRA_INTENT) userAction?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(userAction) } @@ -146,3 +145,5 @@ class ApkInstaller(private val service: PackageInstallerService) { } } +@Suppress("DEPRECATION") +inline fun Intent.getSafeParcelableExtra(key: String): T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelableExtra(key) else getParcelableExtra(key, T::class.java) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt index 1625981e..57b98dc2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt @@ -1,19 +1,21 @@ package com.lagradost.cloudstream3.utils -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import kotlinx.coroutines.delay @@ -47,25 +49,17 @@ class PackageInstallerService : Service() { .setSmallIcon(R.drawable.rdload) } - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = - NotificationChannel(UPDATE_CHANNEL_ID, UPDATE_CHANNEL_NAME, importance).apply { - description = UPDATE_CHANNEL_DESCRIPTION - } - - // Register the channel with the system - val notificationManager: NotificationManager = - this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(channel) - } - } - override fun onCreate() { - createNotificationChannel() - startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) + this.createNotificationChannel( + UPDATE_CHANNEL_ID, + UPDATE_CHANNEL_NAME, + UPDATE_CHANNEL_DESCRIPTION + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else{ + startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) + } } private val updateLock = Mutex() @@ -82,7 +76,7 @@ class PackageInstallerService : Service() { this@PackageInstallerService.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { - it.deleteOnExit() + deleteFileOnExit(it) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt new file mode 100644 index 00000000..1e572fb7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -0,0 +1,94 @@ +package com.lagradost.cloudstream3.utils +//Reference: https://stackoverflow.com/a/29055283 +import android.content.Context +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.util.AttributeSet + +class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { + private var mCropYCenterOffsetPct: Float? = null + private var mCropXCenterOffsetPct: Float? = null + constructor(context: Context?) : super(context!!) + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) + constructor( + context: Context?, attrs: AttributeSet?, + defStyle: Int + ) : super(context!!, attrs, defStyle) + + var cropYCenterOffsetPct: Float + get() = mCropYCenterOffsetPct!! + set(cropYCenterOffsetPct) { + require(cropYCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } + mCropYCenterOffsetPct = cropYCenterOffsetPct + } + var cropXCenterOffsetPct: Float + get() = mCropXCenterOffsetPct!! + set(cropXCenterOffsetPct) { + require(cropXCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } + mCropXCenterOffsetPct = cropXCenterOffsetPct + } + + private fun myConfigureBounds() { + if (this.scaleType == ScaleType.MATRIX) { + + val d = this.drawable + if (d != null) { + val dWidth = d.intrinsicWidth + val dHeight = d.intrinsicHeight + val m = Matrix() + val vWidth = width - this.paddingLeft - this.paddingRight + val vHeight = height - this.paddingTop - this.paddingBottom + val scale: Float + var dx = 0f + var dy = 0f + if (dWidth * vHeight > vWidth * dHeight) { + val cropXCenterOffsetPct = + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!!.toFloat() else 0.5f + scale = vHeight.toFloat() / dHeight.toFloat() + dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct + } else { + val cropYCenterOffsetPct = + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!!.toFloat() else 0f + scale = vWidth.toFloat() / dWidth.toFloat() + dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct + } + m.setScale(scale, scale) + m.postTranslate((dx + 0.5f).toInt().toFloat(), (dy + 0.5f).toInt().toFloat()) + this.imageMatrix = m + } + } + } + + // These 3 methods call configureBounds in ImageView.java class, which + // adjusts the matrix in a call to center_crop (android's built-in + // scaling and centering crop method). We also want to trigger + // in the same place, but using our own matrix, which is then set + // directly at line 588 of ImageView.java and then copied over + // as the draw matrix at line 942 of ImageView.java + override fun setFrame(l: Int, t: Int, r: Int, b: Int): Boolean { + val changed = super.setFrame(l, t, r, b) + myConfigureBounds() + return changed + } + + override fun setImageDrawable(d: Drawable?) { + super.setImageDrawable(d) + myConfigureBounds() + } + + override fun setImageResource(resId: Int) { + super.setImageResource(resId) + myConfigureBounds() + } + // In case you can change the ScaleType in code you have to call redraw() + //fullsizeImageView.setScaleType(ScaleType.FIT_CENTER); + //fullsizeImageView.redraw(); + fun redraw() { + val d = this.drawable + if (d != null) { + // Force toggle to recalculate our bounds + setImageDrawable(null) + setImageDrawable(d) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt new file mode 100644 index 00000000..0d3da8e7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -0,0 +1,86 @@ +package com.lagradost.cloudstream3.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.appcompat.app.AlertDialog +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout + +private const val PACKAGE_NAME = BuildConfig.APPLICATION_ID +private const val TAG = "PowerManagerAPI" + +object BatteryOptimizationChecker { + + fun isAppRestricted(context: Context?): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context != null) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return !powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + return false // below Marshmallow, it's always unrestricted when app is in background + } + + fun openBatteryOptimizationSettings(context: Context) { + if (shouldShowBatteryOptimizationDialog(context)) { + showBatteryOptimizationDialog(context) + } + } + + fun showBatteryOptimizationDialog(context: Context) { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + + try { + context.let { + AlertDialog.Builder(it) + .setTitle(R.string.battery_dialog_title) + .setIcon(R.drawable.ic_battery) + .setMessage(R.string.battery_dialog_message) + .setPositiveButton(R.string.ok) { _, _ -> + intentOpenAppInfo(it) + } + .setNegativeButton(R.string.cancel) { _, _ -> + settingsManager.edit() + .putBoolean(context.getString(R.string.battery_optimisation_key), false) + .apply() + } + .show() + } + } catch (t: Throwable) { + Log.e(TAG, "Error showing battery optimization dialog", t) + } + } + + private fun shouldShowBatteryOptimizationDialog(context: Context): Boolean { + val isRestricted = isAppRestricted(context) + val isOptimizedNotShown = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.battery_optimisation_key), true) + return isRestricted && isOptimizedNotShown && isLayout(PHONE) + } + + private fun intentOpenAppInfo(context: Context) { + val intent = Intent() + try { + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", PACKAGE_NAME, null)) + context.startActivity(intent, Bundle()) + } catch (t: Throwable) { + Log.e(TAG, "Unable to invoke any intent", t) + if (t is ActivityNotFoundException) { + showToast("Exception: Activity Not Found") + } else { + showToast(R.string.app_info_intent_error) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt index 2dc6846c..70edf80c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -2,19 +2,31 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.app.Dialog +import android.text.Spanned +import android.view.LayoutInflater import android.view.View -import android.widget.* +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AlertDialog -import androidx.core.view.* +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding +import com.lagradost.cloudstream3.databinding.BottomSelectionDialogBinding +import com.lagradost.cloudstream3.databinding.BottomTextDialogBinding +import com.lagradost.cloudstream3.databinding.OptionsPopupTvBinding +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.add_account_input.* -import kotlinx.android.synthetic.main.add_account_input.text1 -import kotlinx.android.synthetic.main.bottom_selection_dialog_direct.* object SingleSelectionHelper { fun Activity?.showOptionSelectStringRes( @@ -44,15 +56,15 @@ object SingleSelectionHelper { ) { if (this == null) return - if (isTvSettings()) { - val builder = - AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(R.layout.options_popup_tv) + if (isLayout(TV or EMULATOR)) { + val binding = OptionsPopupTvBinding.inflate(layoutInflater) + val dialog = AlertDialog.Builder(this, R.style.AlertDialogCustom) + .setView(binding.root) + .create() - val dialog = builder.create() dialog.show() - dialog.findViewById(R.id.listview1)?.let { listView -> + binding.listview1.let { listView -> listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE listView.adapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice_color).apply { @@ -65,7 +77,7 @@ object SingleSelectionHelper { } } - dialog.findViewById(R.id.imageView)?.apply { + binding.imageView.apply { isGone = poster.isNullOrEmpty() setImage(poster) } @@ -82,6 +94,7 @@ object SingleSelectionHelper { } fun Activity?.showDialog( + binding: BottomSelectionDialogBinding, dialog: Dialog, items: List, selectedIndex: List, @@ -95,39 +108,39 @@ object SingleSelectionHelper { if (this == null) return val realShowApply = showApply || isMultiSelect - val listView = dialog.listview1//.findViewById(R.id.listview1)!! - val textView = dialog.text1//.findViewById(R.id.text1)!! - val applyButton = dialog.apply_btt//.findViewById(R.id.apply_btt) - val cancelButton = dialog.cancel_btt//findViewById(R.id.cancel_btt) + val listView = binding.listview1 + val textView = binding.text1 + val applyButton = binding.applyBtt + val cancelButton = binding.cancelBtt val applyHolder = - dialog.apply_btt_holder//.findViewById(R.id.apply_btt_holder) + binding.applyBttHolder - applyHolder?.isVisible = realShowApply + applyHolder.isVisible = realShowApply if (!realShowApply) { val params = listView.layoutParams as LinearLayout.LayoutParams params.setMargins(listView.marginLeft, listView.marginTop, listView.marginRight, 0) listView.layoutParams = params } - textView?.text = name - textView?.isGone = name.isBlank() + textView.text = name + textView.isGone = name.isBlank() val arrayAdapter = ArrayAdapter(this, itemLayout) arrayAdapter.addAll(items) - listView?.adapter = arrayAdapter + listView.adapter = arrayAdapter if (isMultiSelect) { - listView?.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + listView.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE } else { - listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE + listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE } for (select in selectedIndex) { - listView?.setItemChecked(select, true) + listView.setItemChecked(select, true) } selectedIndex.minOrNull()?.let { - listView?.setSelection(it) + listView.setSelection(it) } // var lastSelectedIndex = if(selectedIndex.isNotEmpty()) selectedIndex.first() else -1 @@ -136,7 +149,7 @@ object SingleSelectionHelper { dismissCallback.invoke() } - listView?.setOnItemClickListener { _, _, which, _ -> + listView.setOnItemClickListener { _, _, which, _ -> // lastSelectedIndex = which if (realShowApply) { if (!isMultiSelect) { @@ -148,7 +161,7 @@ object SingleSelectionHelper { } } if (realShowApply) { - applyButton?.setOnClickListener { + applyButton.setOnClickListener { val list = ArrayList() for (index in 0 until listView.count) { if (listView.checkedItemPositions[index]) @@ -157,14 +170,14 @@ object SingleSelectionHelper { callback.invoke(list) dialog.dismissSafe(this) } - cancelButton?.setOnClickListener { + cancelButton.setOnClickListener { dialog.dismissSafe(this) } } } - private fun Activity?.showInputDialog( + binding: BottomInputDialogBinding, dialog: Dialog, value: String, name: String, @@ -174,11 +187,11 @@ object SingleSelectionHelper { ) { if (this == null) return - val inputView = dialog.findViewById(R.id.nginx_text_input)!! - val textView = dialog.findViewById(R.id.text1)!! - val applyButton = dialog.findViewById(R.id.apply_btt)!! - val cancelButton = dialog.findViewById(R.id.cancel_btt)!! - val applyHolder = dialog.findViewById(R.id.apply_btt_holder)!! + val inputView = binding.nginxTextInput + val textView = binding.text1 + val applyButton = binding.applyBtt + val cancelButton = binding.cancelBtt + val applyHolder = binding.applyBttHolder applyHolder.isVisible = true textView.text = name @@ -213,13 +226,26 @@ object SingleSelectionHelper { ) { if (this == null) return + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(R.layout.bottom_selection_dialog) + .setView(binding.root) val dialog = builder.create() dialog.show() - showDialog(dialog, items, selectedIndex, name, true, true, callback, dismissCallback) + showDialog( + binding, + dialog, + items, + selectedIndex, + name, + showApply = true, + isMultiSelect = true, + callback, + dismissCallback + ) } fun Activity?.showDialog( @@ -232,13 +258,19 @@ object SingleSelectionHelper { ) { if (this == null) return + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(R.layout.bottom_selection_dialog) + .setView(binding.root) val dialog = builder.create() dialog.show() + + showDialog( + binding, dialog, items, listOf(selectedIndex), @@ -250,17 +282,6 @@ object SingleSelectionHelper { ) } - fun showBottomDialog( - items: List, - selectedIndex: Int, - name: String, - showApply: Boolean, - dismissCallback: () -> Unit, - callback: (Int) -> Unit, - ) { - - } - /** Only for a low amount of items */ fun Activity?.showBottomDialog( items: List, @@ -271,12 +292,18 @@ object SingleSelectionHelper { callback: (Int) -> Unit, ) { if (this == null) return + + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) + val builder = BottomSheetDialog(this) - builder.setContentView(R.layout.bottom_selection_dialog) + builder.setContentView(binding.root) builder.show() showDialog( + binding, builder, items, listOf(selectedIndex), @@ -296,13 +323,19 @@ object SingleSelectionHelper { ): BottomSheetDialog { val builder = BottomSheetDialog(this) - builder.setContentView(R.layout.bottom_selection_dialog_direct) + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) + + //builder.setContentView(R.layout.bottom_selection_dialog_direct) + builder.setContentView(binding.root) builder.show() showDialog( + binding, builder, items, - listOf(), + emptyList(), name, showApply = false, isMultiSelect = false, @@ -320,11 +353,17 @@ object SingleSelectionHelper { dismissCallback: () -> Unit, callback: (String) -> Unit, ) { - val builder = BottomSheetDialog(this) // probably the stuff at the bottom - builder.setContentView(R.layout.bottom_input_dialog) // input layout + val builder = BottomSheetDialog(this) + + val binding: BottomInputDialogBinding = BottomInputDialogBinding.inflate( + LayoutInflater.from(this) + ) + + builder.setContentView(binding.root) builder.show() showInputDialog( + binding, builder, value, name, @@ -333,4 +372,24 @@ object SingleSelectionHelper { dismissCallback ) } + + fun Activity.showBottomDialogText( + title: String, + text: Spanned, + dismissCallback: () -> Unit + ) { + val binding = BottomTextDialogBinding.inflate(layoutInflater) + val dialog = BottomSheetDialog(this) + + dialog.setContentView(binding.root) + + binding.dialogTitle.text = title + binding.dialogText.text = text + + dialog.setOnDismissListener { + dismissCallback.invoke() + } + + dialog.show() + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt new file mode 100644 index 00000000..e6a77795 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt @@ -0,0 +1,84 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Activity +import android.view.View +import androidx.annotation.MainThread +import androidx.annotation.StringRes +import com.google.android.material.snackbar.Snackbar +import com.lagradost.api.Log +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute + +object SnackbarHelper { + + private const val TAG = "COMPACT" + private var currentSnackbar: Snackbar? = null + + @MainThread + fun showSnackbar( + act: Activity?, + message: UiText, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: UiText? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, message.asString(act), duration, + actionText?.asString(act), actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + @StringRes message: Int, + duration: Int = Snackbar.LENGTH_SHORT, + @StringRes actionText: Int? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, act.getString(message), duration, + actionText?.let { act.getString(it) }, actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + message: String?, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: String? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null || message == null) { + Log.w(TAG, "Invalid showSnackbar: act = $act, message = $message") + return + } + Log.i(TAG, "showSnackbar: $message") + + try { + currentSnackbar?.dismiss() + } catch (e: Exception) { + logError(e) + } + + try { + val parentView = act.findViewById(android.R.id.content) + val snackbar = Snackbar.make(parentView, message, duration) + + actionCallback?.let { + snackbar.setAction(actionText) { actionCallback.invoke() } + } + + snackbar.show() + currentSnackbar = snackbar + + snackbar.setBackgroundTint(act.colorFromAttribute(R.attr.primaryBlackBackground)) + snackbar.setTextColor(act.colorFromAttribute(R.attr.textColor)) + snackbar.setActionTextColor(act.colorFromAttribute(R.attr.colorPrimary)) + + } catch (e: Exception) { + logError(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt index 33f1b6ff..09f5e0f1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt @@ -153,9 +153,11 @@ object SubtitleHelper { private val flags = mapOf( "af" to "ZA", "agq" to "CM", + "ajp" to "SY", "ak" to "GH", "am" to "ET", "ar" to "AE", + "ars" to "SA", "as" to "IN", "asa" to "TZ", "az" to "AZ", @@ -515,4 +517,4 @@ object SubtitleHelper { Language639("Zhuang", "Saɯ cueŋƅ, Saw cuengh", "za", "zha", "zha", "zha", ""), Language639("Zulu", "isiZulu", "zu", "zul", "zul", "zul", ""), ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt new file mode 100644 index 00000000..93a53395 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -0,0 +1,56 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import com.lagradost.api.Log +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.safefile.SafeFile + +object SubtitleUtils { + + // Only these files are allowed, so no videos as subtitles + private val allowedExtensions = listOf( + ".vtt", ".srt", ".txt", ".ass", + ".ttml", ".sbv", ".dfxp" + ) + + fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + val relative = info.relativePath + val display = info.displayName + val cleanDisplay = cleanDisplayName(display) + + getFolder(context, relative, info.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { + val subtitleFile = SafeFile.fromUri(context, uri) + if (subtitleFile == null || !subtitleFile.delete()) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}") + } + } + } + } + + /** + * @param name the file name of the subtitle + * @param display the file name of the video + * @param cleanDisplay the cleanDisplayName of the video file name + */ + fun isMatchingSubtitle( + name: String, + display: String, + cleanDisplay: String + ): Boolean { + // Check if the file has a valid subtitle extension + val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } + + // We can't have the exact same file as a subtitle + val isNotDisplayName = !name.equals(display, ignoreCase = true) + + // Check if the file name starts with a cleaned version of the display name + val startsWithCleanDisplay = cleanDisplayName(name).startsWith(cleanDisplay, ignoreCase = true) + + return hasValidExtension && isNotDisplayName && startsWithCleanDisplay + } + + fun cleanDisplayName(name: String): String { + return name.substringBeforeLast('.').trim() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index e5f2f2dc..351e77c8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -13,7 +13,7 @@ import java.util.concurrent.TimeUnit object SyncUtil { private val regexs = listOf( - Regex("""(9anime)\.(?:to|center|id)/watch/(?:.*?)\.([^/?]*)"""), + Regex("""(9anime)\.(?:to|center|id)/watch/.*?\.([^/?]*)"""), Regex("""(gogoanime|gogoanimes)\..*?/category/([^/?]*)"""), Regex("""(twist\.moe)/a/([^/?]*)"""), ) @@ -44,6 +44,13 @@ object SyncUtil { matchList[site]?.let { realSite -> getIdsFromSlug(slug, realSite)?.let { return it + } ?: kotlin.run { + if (slug.endsWith("-dub")) { + println("testing non -dub slug $slug") + getIdsFromSlug(slug.removeSuffix("-dub"), realSite)?.let { + return it + } + } } } } @@ -66,8 +73,8 @@ object SyncUtil { val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text val mapped = parseJson(response) - val overrideMal = mapped?.malId ?: mapped?.Mal?.id ?: mapped?.Anilist?.malId - val overrideAnilist = mapped?.aniId ?: mapped?.Anilist?.id + val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId + val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id if (overrideMal != null) { return overrideMal.toString() to overrideAnilist?.toString() @@ -89,8 +96,10 @@ object SyncUtil { .mapNotNull { it.url }.toMutableList() if (type == "anilist") { // TODO MAKE BETTER - apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { - current.add("${it.mainUrl}/anime/$id") + synchronized(apis) { + apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { + current.add("${it.mainUrl}/anime/$id") + } } } return current @@ -126,8 +135,8 @@ object SyncUtil { @JsonProperty("createdAt") val createdAt: String?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String?, - @JsonProperty("Mal") val Mal: Mal?, - @JsonProperty("Anilist") val Anilist: Anilist?, + @JsonProperty("Mal") val mal: Mal?, + @JsonProperty("Anilist") val anilist: Anilist?, @JsonProperty("malUrl") val malUrl: String? ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt new file mode 100644 index 00000000..049f92fb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -0,0 +1,324 @@ +package com.lagradost.cloudstream3.utils + +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.mvvm.logError +import kotlinx.coroutines.* +import org.junit.Assert +import kotlin.random.Random + +object TestingUtils { + open class TestResult(val success: Boolean) { + companion object { + val Pass = TestResult(true) + val Fail = TestResult(false) + } + } + + class Logger { + enum class LogLevel { + Normal, + Warning, + Error; + } + + data class Message(val level: LogLevel, val message: String) { + override fun toString(): String { + val level = when (this.level) { + LogLevel.Normal -> "" + LogLevel.Warning -> "Warning: " + LogLevel.Error -> "Error: " + } + return "$level$message" + } + } + + private val messageLog = mutableListOf() + + fun getRawLog(): List = messageLog + + fun log(message: String) { + messageLog.add(Message(LogLevel.Normal, message)) + } + + fun warn(message: String) { + messageLog.add(Message(LogLevel.Warning, message)) + } + + fun error(message: String) { + messageLog.add(Message(LogLevel.Error, message)) + } + } + + class TestResultList(val results: List) : TestResult(true) + class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true) + + class TestResultProvider( + success: Boolean, + val log: List, + val exception: Throwable? + ) : + TestResult(success) + + @Throws(AssertionError::class, CancellationException::class) + suspend fun testHomepage( + api: MainAPI, + logger: Logger + ): TestResult { + if (api.hasMainPage) { + try { + val f = api.mainPage.first() + val homepage = + api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) + when { + homepage == null -> { + logger.error("Provider ${api.name} did not correctly load homepage!") + } + + homepage.items.isEmpty() -> { + logger.warn("Provider ${api.name} does not contain any homepage rows!") + } + + homepage.items.any { it.list.isEmpty() } -> { + logger.warn("Provider ${api.name} does not have any items in a homepage row!") + } + } + val homePageList = homepage?.items?.flatMap { it.list } ?: emptyList() + return TestResultList(homePageList) + } catch (e: Throwable) { + when (e) { + is NotImplementedError -> { + Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") + } + + is CancellationException -> { + throw e + } + + else -> { + e.message?.let { logger.warn("Exception thrown when loading homepage: \"$it\"") } + } + } + } + } + return TestResult.Pass + } + + @Throws(AssertionError::class, CancellationException::class) + private suspend fun testSearch( + api: MainAPI, + testQueries: List, + logger: Logger, + ): TestResult { + val searchResults = testQueries.firstNotNullOfOrNull { query -> + try { + logger.log("Searching for: $query") + api.search(query).takeIf { !it.isNullOrEmpty() } + } catch (e: Throwable) { + if (e is NotImplementedError) { + Assert.fail("Provider has not implemented search()") + } else if (e is CancellationException) { + throw e + } + logError(e) + null + } + } + + return if (searchResults.isNullOrEmpty()) { + Assert.fail("Api ${api.name} did not return any search responses") + TestResult.Fail // Should not be reached + } else { + TestResultList(searchResults) + } + } + + + @Throws(AssertionError::class, CancellationException::class) + private suspend fun testLoad( + api: MainAPI, + result: SearchResponse, + logger: Logger + ): TestResult { + try { + if (result.apiName != api.name) { + logger.warn("Wrong apiName on SearchResponse: ${api.name} != ${result.apiName}") + } + + val loadResponse = api.load(result.url) + + if (loadResponse == null) { + logger.error("Returned null loadResponse on ${result.url} on ${api.name}") + return TestResult.Fail + } + + if (loadResponse.apiName != api.name) { + logger.warn("Wrong apiName on LoadResponse: ${api.name} != ${loadResponse.apiName}") + } + + if (!api.supportedTypes.contains(loadResponse.type)) { + logger.warn("Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}") + } + + val url = when (loadResponse) { + is AnimeLoadResponse -> { + val gotNoEpisodes = + loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() } + + if (gotNoEpisodes) { + logger.error("Api ${api.name} got no episodes on ${loadResponse.url}") + return TestResult.Fail + } + + (loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data + } + + is MovieLoadResponse -> { + val gotNoEpisodes = loadResponse.dataUrl.isBlank() + if (gotNoEpisodes) { + logger.error("Api ${api.name} got no movie on ${loadResponse.url}") + return TestResult.Fail + } + + loadResponse.dataUrl + } + + is TvSeriesLoadResponse -> { + val gotNoEpisodes = loadResponse.episodes.isEmpty() + if (gotNoEpisodes) { + logger.error("Api ${api.name} got no episodes on ${loadResponse.url}") + return TestResult.Fail + } + loadResponse.episodes.firstOrNull()?.data + } + + is LiveStreamLoadResponse -> { + loadResponse.dataUrl + } + + else -> { + logger.error("Unknown load response: ${loadResponse.javaClass.name}") + return TestResult.Fail + } + } ?: return TestResult.Fail + + return TestResultLoad(url, loadResponse.type != TvType.CustomMedia) + +// val loadTest = testLoadResponse(api, load, logger) +// if (loadTest is TestResultLoad) { +// testLinkLoading(api, loadTest.extractorData, logger).success +// } else { +// false +// } +// if (!validResults) { +// logger("Api ${api.name} did not load on the first search results: ${smallSearchResults.map { it.name }}") +// } + +// return TestResult(validResults) + } catch (e: Throwable) { + if (e is NotImplementedError) { + Assert.fail("Provider has not implemented load()") + } + throw e + } + } + + @Throws(AssertionError::class, CancellationException::class) + private suspend fun testLinkLoading( + api: MainAPI, + url: String?, + logger: Logger + ): TestResult { + Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) + if (url == null) return TestResult.Fail // Should never trigger + + var linksLoaded = 0 + try { + val success = api.loadLinks(url, false, {}) { link -> + logger.log("Video loaded: ${link.name}") + Assert.assertTrue( + "Api ${api.name} returns link with invalid url ${link.url}", + link.url.length > 4 + ) + linksLoaded++ + } + if (success) { + logger.log("Links loaded: $linksLoaded") + return TestResult(linksLoaded > 0) + } else { + Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") + } + } catch (e: Throwable) { + when (e) { + is NotImplementedError -> { + Assert.fail("Provider has not implemented loadLinks()") + } + + else -> { + logger.error("Failed link loading on ${api.name} using data: $url") + throw e + } + } + } + return TestResult.Pass + } + + fun getDeferredProviderTests( + scope: CoroutineScope, + providers: Array, + callback: (MainAPI, TestResultProvider) -> Unit + ) { + providers.forEach { api -> + scope.launch { + val logger = Logger() + + val result = try { + logger.log("Trying ${api.name}") + + // Test Homepage + val homepage = testHomepage(api, logger) + Assert.assertTrue("Homepage failed to load", homepage.success) + val homePageList = (homepage as? TestResultList)?.results ?: emptyList() + + // Test Search Results + val searchQueries = + // Use the random 3 home page results as queries since they are guaranteed to exist + (homePageList.shuffled(Random).take(3).map { it.name.split(" ").first() } + + // If home page is sparse then use generic search queries + listOf("over", "iron", "guy")).take(3) + + val searchResults = testSearch(api, searchQueries, logger) + Assert.assertTrue("Failed to get search results", searchResults.success) + searchResults as TestResultList + + // Test Load and LoadLinks + // Only try the first 3 search results to prevent spamming + val success = searchResults.results.take(3).any { searchResponse -> + logger.log("Testing search result: ${searchResponse.url}") + val loadResponse = testLoad(api, searchResponse, logger) + if (loadResponse !is TestResultLoad) { + false + } else { + if (loadResponse.shouldLoadLinks) { + testLinkLoading(api, loadResponse.extractorData, logger).success + } else { + logger.log("Skipping link loading test") + true + } + } + } + + if (success) { + logger.log("Success ${api.name}") + TestResultProvider(true, logger.getRawLog(), null) + } else { + logger.error("Link loading failed") + TestResultProvider(false, logger.getRawLog(), null) + } + } catch (e: Throwable) { + TestResultProvider(false, logger.getRawLog(), e) + } + callback.invoke(api, result) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index c300d615..ad1b6502 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -5,6 +5,8 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.AppOpsManager import android.app.Dialog +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager import android.content.res.Configuration @@ -14,25 +16,38 @@ import android.graphics.Color import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.TransactionTooLargeException +import android.util.Log import android.view.* +import android.view.ViewGroup.MarginLayoutParams import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.ListAdapter import android.widget.ListView +import android.widget.Toast.LENGTH_LONG import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.IdRes +import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.drawable.toBitmapOrNull import androidx.core.graphics.green import androidx.core.graphics.red +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop +import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.NavHostFragment @@ -44,23 +59,33 @@ import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions.bitmapTransform import com.bumptech.glide.request.target.Target +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipDrawable +import com.google.android.material.chip.ChipGroup +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.GlideOptions.bitmapTransform +import com.lagradost.cloudstream3.ui.result.UiImage +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import jp.wasabeef.glide.transformations.BlurTransformation import kotlin.math.roundToInt - object UIHelper { val Int.toPx: Int get() = (this * Resources.getSystem().displayMetrics.density).toInt() val Float.toPx: Float get() = (this * Resources.getSystem().displayMetrics.density) val Int.toDp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() val Float.toDp: Float get() = (this / Resources.getSystem().displayMetrics.density) - fun Activity.checkWrite(): Boolean { + fun Context.checkWrite(): Boolean { return (ContextCompat.checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE @@ -71,6 +96,31 @@ object UIHelper { || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) } + fun populateChips(view: ChipGroup?, tags: List, @StyleRes style : Int = R.style.ChipFilled) { + if (view == null) return + view.removeAllViews() + val context = view.context ?: return + val maxTags = tags.take(10) // Limited because they are too much + + maxTags.forEach { tag -> + val chip = Chip(context) + val chipDrawable = ChipDrawable.createFromAttributes( + context, + null, + 0, + style + ) + chip.setChipDrawable(chipDrawable) + chip.text = tag + chip.isChecked = false + chip.isCheckable = false + chip.isFocusable = false + chip.isClickable = false + chip.setTextColor(context.colorFromAttribute(R.attr.white)) + view.addView(chip) + } + } + fun Activity.requestRW() { ActivityCompat.requestPermissions( this, @@ -83,6 +133,35 @@ object UIHelper { ) } + fun clipboardHelper(label: UiText, text: CharSequence) { + val ctx = context ?: return + try { + ctx.let { + val clip = ClipData.newPlainText(label.asString(ctx), text) + val labelSuffix = txt(R.string.toast_copied).asString(ctx) + ctx.getSystemService()?.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + showToast("${label.asString(ctx)} $labelSuffix") + } + } + } catch (t: Throwable) { + Log.e("ClipboardService", "$t") + when (t) { + is SecurityException -> { + showToast(R.string.clipboard_permission_error) + } + + is TransactionTooLargeException -> { + showToast(R.string.clipboard_too_large) + } + + else -> { + showToast(R.string.clipboard_unknown_error, LENGTH_LONG) + } + } + } + } /** * Sets ListView height dynamically based on the height of the items. @@ -133,6 +212,14 @@ object UIHelper { } } + fun View?.setAppBarNoScrollFlagsOnTV() { + if (isLayout(Globals.TV or EMULATOR)) { + this?.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL + } + } + } + fun Activity.hideKeyboard() { window?.decorView?.clearFocus() this.findViewById(android.R.id.content)?.rootView?.let { @@ -143,9 +230,8 @@ object UIHelper { fun Activity?.navigate(@IdRes navigation: Int, arguments: Bundle? = null) { try { if (this is FragmentActivity) { - (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.navController?.navigate( - navigation, arguments - ) + val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment? + navHostFragment?.navController?.navigate(navigation, arguments) } } catch (t: Throwable) { logError(t) @@ -180,19 +266,93 @@ object UIHelper { } } + /*inline fun bindViewBinding( + inflater: LayoutInflater?, + container: ViewGroup?, + layout: Int + ): Pair { + return try { + val localInflater = inflater ?: container?.context?.let { LayoutInflater.from(it) } + ?: return null to txt( + R.string.unable_to_inflate, + "Requires inflater OR container" + )//throw IllegalArgumentException("Requires inflater OR container")) + + //println("methods: ${T::class.java.methods.map { it.name }}") + val bind = T::class.java.methods.first { it.name == "bind" } + //val inflate = T::class.java.methods.first { it.name == "inflate" } + val root = localInflater.inflate(layout, container, false) + bind.invoke(null, root) as T to null + } catch (t: Throwable) { + logError(t) + val message = txt(R.string.unable_to_inflate, t.message ?: "Primary constructor") + // if the desired layout is not found then we inflate the casted layout + /*try { + val localInflater = inflater ?: container?.context?.let { LayoutInflater.from(it) } + ?: return null to txt( + R.string.unable_to_inflate, + "Requires inflater OR container" + )//throw IllegalArgumentException("Requires inflater OR container")) + + // we don't know what method to use as there are 2, but first *should* always be true + return try { + val inflate = T::class.java.methods.first { it.name == "inflate" } + inflate.invoke(null, localInflater, container, false) as T + } catch (_: Throwable) { + val inflate = T::class.java.methods.last { it.name == "inflate" } + inflate.invoke(null, localInflater, container, false) as T + } to message + } catch (t: Throwable) { + logError(t) + }*/ + + null to message + } + }*/ + fun ImageView?.setImage( url: String?, headers: Map? = null, @DrawableRes errorImageDrawable: Int? = null, fadeIn: Boolean = true, + radius: Int = 0, + sample: Int = 3, colorCallback: ((Palette) -> Unit)? = null ): Boolean { - if (this == null || url.isNullOrBlank()) return false + if (url.isNullOrBlank()) return false + this.setImage( + UiImage.Image(url, headers, errorImageDrawable), + errorImageDrawable, + fadeIn, + radius, + sample, + colorCallback + ) + return true + } + + fun ImageView?.setImage( + uiImage: UiImage?, + @DrawableRes + errorImageDrawable: Int? = null, + fadeIn: Boolean = true, + radius: Int = 0, + sample: Int = 3, + colorCallback: ((Palette) -> Unit)? = null, + ): Boolean { + if (this == null || uiImage == null) return false + + val (glideImage, identifier) = + (uiImage as? UiImage.Drawable)?.resId?.let { + it to it.toString() + } ?: (uiImage as? UiImage.Image)?.let { image -> + GlideUrl(image.url) { image.headers ?: emptyMap() } to image.url + } ?: return false return try { - val builder = GlideApp.with(this) - .load(GlideUrl(url) { headers ?: emptyMap() }) + var builder = com.bumptech.glide.Glide.with(this) + .load(glideImage) .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.ALL).let { req -> if (fadeIn) @@ -200,26 +360,35 @@ object UIHelper { else req } + if (radius > 0) { + builder = builder.apply(bitmapTransform(BlurTransformation(radius, sample))) + } + if (colorCallback != null) { - builder.listener(object : RequestListener { - @SuppressLint("CheckResult") + builder = builder.listener(object : RequestListener { + override fun onResourceReady( - resource: Drawable?, - model: Any?, + resource: Drawable, + model: Any, target: Target?, - dataSource: DataSource?, + dataSource: DataSource, isFirstResource: Boolean ): Boolean { - resource?.toBitmapOrNull() - ?.let { bitmap -> createPaletteAsync(url, bitmap, colorCallback) } + resource.toBitmapOrNull() + ?.let { bitmap -> + createPaletteAsync( + identifier, + bitmap, + colorCallback + ) + } return false } - @SuppressLint("CheckResult") override fun onLoadFailed( e: GlideException?, model: Any?, - target: Target?, + target: Target, isFirstResource: Boolean ): Boolean { return false @@ -248,7 +417,7 @@ object UIHelper { ) { if (this == null || url.isNullOrBlank()) return try { - val res = GlideApp.with(this) + val res = com.bumptech.glide.Glide.with(this) .load(GlideUrl(url) { headers ?: emptyMap() }) .apply(bitmapTransform(BlurTransformation(radius, sample))) .transition( @@ -282,84 +451,53 @@ object UIHelper { // Enables regular immersive mode. // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY - window.decorView.systemUiVisibility = ( - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - // Set the content to appear under the system bars so that the - // content doesn't resize when the system bars hide and show. - or View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - // Hide the nav bar and status bar - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN - // or View.SYSTEM_UI_FLAG_LOW_PROFILE - ) - // window.addFlags(View.KEEP_SCREEN_ON) - } + /** BUGGED AF **/ + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - fun FragmentActivity.popCurrentPage() { - this.onBackPressed() - /*val currentFragment = supportFragmentManager.fragments.lastOrNull { - it.isVisible - } ?: return - - supportFragmentManager.beginTransaction() - .setCustomAnimations( - R.anim.enter_anim, - R.anim.exit_anim, - R.anim.pop_enter, - R.anim.pop_exit - ) - .remove(currentFragment) - .commitAllowingStateLoss()*/ - } - /* - fun FragmentActivity.popCurrentPage(isInPlayer: Boolean, isInExpandedView: Boolean, isInResults: Boolean) { - val currentFragment = supportFragmentManager.fragments.lastOrNull { - it.isVisible - } - ?: //this.onBackPressed() - return - -/* - if (tvActivity == null) { - requestedOrientation = if (settingsManager?.getBoolean("force_landscape", false) == true) { - ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, View(this)).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } }*/ - // No fucked animations leaving the player :) - when { - isInPlayer -> { - supportFragmentManager.beginTransaction() - //.setCustomAnimations(R.anim.enter, R.anim.exit, R.anim.pop_enter, R.anim.pop_exit) - .remove(currentFragment) - .commitAllowingStateLoss() - } - isInExpandedView && !isInResults -> { - supportFragmentManager.beginTransaction() - .setCustomAnimations( - R.anim.enter_anim,//R.anim.enter_from_right, - R.anim.exit_anim,//R.anim.exit_to_right, - R.anim.pop_enter, - R.anim.pop_exit + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + // Set the content to appear under the system bars so that the + // content doesn't resize when the system bars hide and show. + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + // Hide the nav bar and status bar + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN ) - .remove(currentFragment) - .commitAllowingStateLoss() - } - else -> { - supportFragmentManager.beginTransaction() - .setCustomAnimations(R.anim.enter_anim, R.anim.exit_anim, R.anim.pop_enter, R.anim.pop_exit) - .remove(currentFragment) - .commitAllowingStateLoss() + //} + } + + fun FragmentActivity.popCurrentPage() { + // Post the back press action to the main thread handler to ensure it executes + // after any currently pending UI updates or fragment transactions. + Handler(Looper.getMainLooper()).post { + // Check if the FragmentManager state is saved. If it is, we cannot perform + // fragment transactions safely because the state may be inconsistent. + if (!supportFragmentManager.isStateSaved) { + // If the state is not saved, it's safe to perform the back press action. + this.onBackPressedDispatcher.onBackPressed() + } else { + // If the state is saved, retry the back press action after a slight delay. + // This gives the FragmentManager time to complete any ongoing state-saving + // operations or transactions, ensuring that we do not encounter an IllegalStateException. + Handler(Looper.getMainLooper()).postDelayed({ + this.onBackPressedDispatcher.onBackPressed() + }, 100) } } - }*/ + } fun Context.getStatusBarHeight(): Int { - if (isTvSettings()) { + if (isLayout(Globals.TV or EMULATOR)) { return 0 } @@ -371,21 +509,38 @@ object UIHelper { return result } - fun Context?.fixPaddingStatusbar(v: View?) { - if (v == null || this == null) return + fun fixPaddingStatusbar(v: View?) { + if (v == null) return + val ctx = v.context ?: return v.setPadding( v.paddingLeft, - v.paddingTop + getStatusBarHeight(), + v.paddingTop + ctx.getStatusBarHeight(), v.paddingRight, v.paddingBottom ) } - fun Context.fixPaddingStatusbarView(v: View?) { + fun fixPaddingStatusbarMargin(v: View?) { if (v == null) return + val ctx = v.context ?: return + v.layoutParams = v.layoutParams.apply { + if (this is MarginLayoutParams) { + setMargins( + v.marginLeft, + v.marginTop + ctx.getStatusBarHeight(), + v.marginRight, + v.marginBottom + ) + } + } + } + + fun fixPaddingStatusbarView(v: View?) { + if (v == null) return + val ctx = v.context ?: return val params = v.layoutParams - params.height = getStatusBarHeight() + params.height = ctx.getStatusBarHeight() v.layoutParams = params } @@ -398,7 +553,7 @@ object UIHelper { return result } - fun Context?.IsBottomLayout(): Boolean { + fun Context?.isBottomLayout(): Boolean { if (this == null) return true val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) @@ -406,13 +561,26 @@ object UIHelper { fun Activity.changeStatusBarState(hide: Boolean): Int { return if (hide) { - window?.setFlags( - WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN - ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.hide(WindowInsets.Type.statusBars()) + + } else { + @Suppress("DEPRECATION") + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) + } 0 } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.show(WindowInsets.Type.statusBars()) + + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } + this.getStatusBarHeight() } } @@ -420,13 +588,18 @@ object UIHelper { // Shows the system bars by removing all the flags // except for the ones that make the content appear under the system bars. fun Activity.showSystemUI() { - window.decorView.systemUiVisibility = - (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - changeStatusBarState(isEmulatorSettings()) + WindowCompat.setDecorFitsSystemWindows(window, true) + WindowInsetsControllerCompat(window, View(this)).show(WindowInsetsCompat.Type.systemBars()) - // window.clearFlags(View.KEEP_SCREEN_ON) + } else {*/ /** WINDOW COMPAT IS BUGGY DUE TO FU*KED UP PLAYER AND TRAILERS **/ + window.decorView.systemUiVisibility = + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + //} + + changeStatusBarState(isLayout(EMULATOR)) } fun Context.shouldShowPIPMode(isInPlayer: Boolean): Boolean { @@ -478,7 +651,7 @@ object UIHelper { } fun Dialog?.dismissSafe() { - if (this?.isShowing == true) { + if (this?.isShowing == true && activity?.isFinishing != true) { this.dismiss() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt index a76cc115..30f66f83 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -2,20 +2,22 @@ package com.lagradost.cloudstream3.utils import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.download.EasyDownloadButton - object VideoDownloadHelper { + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + data class DownloadEpisodeCached( @JsonProperty("name") val name: String?, @JsonProperty("poster") val poster: String?, @JsonProperty("episode") val episode: Int, @JsonProperty("season") val season: Int?, - @JsonProperty("id") override val id: Int, @JsonProperty("parentId") val parentId: Int, @JsonProperty("rating") val rating: Int?, @JsonProperty("description") val description: String?, @JsonProperty("cacheTime") val cacheTime: Long, - ) : EasyDownloadButton.IMinimumData + override val id: Int, + ): DownloadCached(id) data class DownloadHeaderCached( @JsonProperty("apiName") val apiName: String, @@ -23,9 +25,9 @@ object VideoDownloadHelper { @JsonProperty("type") val type: TvType, @JsonProperty("name") val name: String, @JsonProperty("poster") val poster: String?, - @JsonProperty("id") val id: Int, @JsonProperty("cacheTime") val cacheTime: Long, - ) + override val id: Int, + ): DownloadCached(id) data class ResumeWatching( @JsonProperty("parentId") val parentId: Int, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index a629dad9..2190e03f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -8,10 +8,7 @@ import android.content.* import android.graphics.Bitmap import android.net.Uri import android.os.Build -import android.os.Environment -import android.provider.MediaStore import androidx.annotation.DrawableRes -import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri @@ -20,37 +17,50 @@ import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import com.bumptech.glide.Glide +import com.bumptech.glide.load.model.GlideUrl import com.fasterxml.jackson.annotation.JsonProperty -import com.hippo.unifile.UniFile +import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.IDownloadableMinimum import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly -import java.io.BufferedInputStream +import java.io.Closeable import java.io.File -import java.io.InputStream +import java.io.IOException import java.io.OutputStream -import java.lang.Thread.sleep -import java.net.URI -import java.net.URL -import java.net.URLConnection +import java.lang.IllegalArgumentException import java.util.* -import kotlin.math.roundToInt const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" @@ -58,34 +68,35 @@ const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { var maxConcurrentDownloads = 3 + var maxConcurrentConnections = 3 private var currentDownloads = mutableListOf() private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" - @DrawableRes - const val imgDone = R.drawable.rddone + @get:DrawableRes + val imgDone get() = R.drawable.rddone - @DrawableRes - const val imgDownloading = R.drawable.rdload + @get:DrawableRes + val imgDownloading get() = R.drawable.rdload - @DrawableRes - const val imgPaused = R.drawable.rdpause + @get:DrawableRes + val imgPaused get() = R.drawable.rdpause - @DrawableRes - const val imgStopped = R.drawable.rderror + @get:DrawableRes + val imgStopped get() = R.drawable.rderror - @DrawableRes - const val imgError = R.drawable.rderror + @get:DrawableRes + val imgError get() = R.drawable.rderror - @DrawableRes - const val pressToPauseIcon = R.drawable.ic_baseline_pause_24 + @get:DrawableRes + val pressToPauseIcon get() = R.drawable.ic_baseline_pause_24 - @DrawableRes - const val pressToResumeIcon = R.drawable.ic_baseline_play_arrow_24 + @get:DrawableRes + val pressToResumeIcon get() = R.drawable.ic_baseline_play_arrow_24 - @DrawableRes - const val pressToStopIcon = R.drawable.exo_icon_stop + @get:DrawableRes + val pressToStopIcon get() = R.drawable.baseline_stop_24 enum class DownloadType { IsPaused, @@ -93,6 +104,7 @@ object VideoDownloadManager { IsDone, IsFailed, IsStopped, + IsPending } enum class DownloadActionType { @@ -101,16 +113,6 @@ object VideoDownloadManager { Stop, } - interface IDownloadableMinimum { - val url: String - val referer: String - val headers: Map - } - - fun IDownloadableMinimum.getId(): Int { - return url.hashCode() - } - data class DownloadEpisodeMetadata( @JsonProperty("id") val id: Int, @JsonProperty("mainName") val mainName: String, @@ -153,26 +155,35 @@ object VideoDownloadManager { @JsonProperty("pkg") val pkg: DownloadResumePackage, ) - private const val SUCCESS_DOWNLOAD_DONE = 1 - private const val SUCCESS_STREAM = 3 - private const val SUCCESS_STOPPED = 2 + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) - // will not download the next one, but is still classified as an error - private const val ERROR_DELETING_FILE = 3 - private const val ERROR_CREATE_FILE = -2 - private const val ERROR_UNKNOWN = -10 + /** Invalid input, just skip to the next one as the same args will give the same error */ + private val DOWNLOAD_INVALID_INPUT = + DownloadStatus(retrySame = false, tryNext = true, success = false) - //private const val ERROR_OPEN_FILE = -3 - private const val ERROR_TOO_SMALL_CONNECTION = -4 + /** no need to try any other mirror as we have downloaded the file */ + private val DOWNLOAD_SUCCESS = + DownloadStatus(retrySame = false, tryNext = false, success = true) - //private const val ERROR_WRONG_CONTENT = -5 - private const val ERROR_CONNECTION_ERROR = -6 + /** the user pressed stop, so no need to download anything else */ + private val DOWNLOAD_STOPPED = + DownloadStatus(retrySame = false, tryNext = false, success = true) - //private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7 - //private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8 - private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9 + /** the process failed due to some reason, so we retry and also try the next mirror */ + private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) - private const val KEY_RESUME_PACKAGES = "download_resume" + /** bad config, skip all mirrors as every call to download will have the same bad config */ + private val DOWNLOAD_BAD_CONFIG = + DownloadStatus(retrySame = false, tryNext = false, success = false) + + const val KEY_RESUME_PACKAGES = "download_resume" const val KEY_DOWNLOAD_INFO = "download_info" private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" @@ -202,31 +213,33 @@ object VideoDownloadManager { } } - /** Will return IsDone if not found or error */ - fun getDownloadState(id: Int): DownloadType { - return try { - downloadStatus[id] ?: DownloadType.IsDone - } catch (e: Exception) { - logError(e) - DownloadType.IsDone - } - } + ///** Will return IsDone if not found or error */ + //fun getDownloadState(id: Int): DownloadType { + // return try { + // downloadStatus[id] ?: DownloadType.IsDone + // } catch (e: Exception) { + // logError(e) + // DownloadType.IsDone + // } + //} private val cachedBitmaps = hashMapOf() - private fun Context.getImageBitmapFromUrl(url: String): Bitmap? { + fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { try { if (cachedBitmaps.containsKey(url)) { return cachedBitmaps[url] } - val bitmap = GlideApp.with(this) + val bitmap = Glide.with(this) .asBitmap() - .load(url).into(720, 720) + .load(GlideUrl(url) { headers ?: emptyMap() }) + .submit(720, 720) .get() + if (bitmap != null) { cachedBitmaps[url] = bitmap } - return null + return bitmap } catch (e: Exception) { logError(e) return null @@ -247,8 +260,8 @@ object VideoDownloadManager { notificationCallback: (Int, Notification) -> Unit, hlsProgress: Long? = null, hlsTotal: Long? = null, - - ): Notification? { + bytesPerSecond: Long + ): Notification? { try { if (total <= 0) return null// crash, invalid data @@ -268,6 +281,7 @@ object VideoDownloadManager { DownloadType.IsPaused -> imgPaused DownloadType.IsFailed -> imgError DownloadType.IsStopped -> imgStopped + DownloadType.IsPending -> imgDownloading } ) @@ -284,6 +298,7 @@ object VideoDownloadManager { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) } else { + //fixme Specify a better flag PendingIntent.getActivity(context, 0, intent, 0) } builder.setContentIntent(pendingIntent) @@ -291,6 +306,8 @@ object VideoDownloadManager { if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false) + } else if (state == DownloadType.IsPending) { + builder.setProgress(0, 0, true) } val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" @@ -317,27 +334,52 @@ object VideoDownloadManager { val totalMbString: String val suffix: String + val mbFormat = "%.1f MB" + if (hlsProgress != null && hlsTotal != null) { progressPercentage = hlsProgress.toLong() * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() - suffix = " - %.1f MB".format(progress / 1000000f) + suffix = " - $mbFormat".format(progress / 1000000f) } else { progressPercentage = progress * 100 / total - progressMbString = "%.1f MB".format(progress / 1000000f) - totalMbString = "%.1f MB".format(total / 1000000f) + progressMbString = mbFormat.format(progress / 1000000f) + totalMbString = mbFormat.format(total / 1000000f) suffix = "" } + val mbPerSecondString = + if (state == DownloadType.IsDownloading) { + " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f) + } else "" + val bigText = - if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { - (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix" - } else if (state == DownloadType.IsFailed) { - downloadFormat.format(context.getString(R.string.download_failed), rowTwo) - } else if (state == DownloadType.IsDone) { - downloadFormat.format(context.getString(R.string.download_done), rowTwo) - } else { - downloadFormat.format(context.getString(R.string.download_canceled), rowTwo) + when (state) { + DownloadType.IsDownloading, DownloadType.IsPaused -> { + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString" + } + + DownloadType.IsPending -> { + (if (linkName == null) "" else "$linkName\n") + rowTwo + } + + DownloadType.IsFailed -> { + downloadFormat.format( + context.getString(R.string.download_failed), + rowTwo + ) + } + + DownloadType.IsDone -> { + downloadFormat.format(context.getString(R.string.download_done), rowTwo) + } + + DownloadType.IsStopped -> { + downloadFormat.format( + context.getString(R.string.download_canceled), + rowTwo + ) + } } val bodyStyle = NotificationCompat.BigTextStyle() @@ -345,14 +387,28 @@ object VideoDownloadManager { builder.setStyle(bodyStyle) } else { val txt = - if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { - rowTwo - } else if (state == DownloadType.IsFailed) { - downloadFormat.format(context.getString(R.string.download_failed), rowTwo) - } else if (state == DownloadType.IsDone) { - downloadFormat.format(context.getString(R.string.download_done), rowTwo) - } else { - downloadFormat.format(context.getString(R.string.download_canceled), rowTwo) + when (state) { + DownloadType.IsDownloading, DownloadType.IsPaused, DownloadType.IsPending -> { + rowTwo + } + + DownloadType.IsFailed -> { + downloadFormat.format( + context.getString(R.string.download_failed), + rowTwo + ) + } + + DownloadType.IsDone -> { + downloadFormat.format(context.getString(R.string.download_done), rowTwo) + } + + DownloadType.IsStopped -> { + downloadFormat.format( + context.getString(R.string.download_canceled), + rowTwo + ) + } } builder.setContentText(txt) @@ -425,64 +481,16 @@ object VideoDownloadManager { } } - private const val reservedChars = "|\\?*<\":>+[]/\'" - fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String { + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { var tempName = name - for (c in reservedChars) { + for (c in RESERVED_CHARS) { tempName = tempName.replace(c, ' ') } if (removeSpaces) tempName = tempName.replace(" ", "") return tempName.replace(" ", " ").trim(' ') } - @RequiresApi(Build.VERSION_CODES.Q) - private fun ContentResolver.getExistingFolderStartName(relativePath: String): List>? { - try { - val projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only) - //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only) - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath'" - - val result = this.query( - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), - projection, selection, null, null - ) - val list = ArrayList>() - - result.use { c -> - if (c != null && c.count >= 1) { - c.moveToFirst() - while (true) { - val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - val name = - c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val uri = ContentUris.withAppendedId( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, id - ) - list.add(Pair(name, uri)) - if (c.isLast) { - break - } - c.moveToNext() - } - - /* - val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/ - - } - } - return list - } catch (e: Exception) { - logError(e) - return null - } - } - /** * Used for getting video player subs. * @return List of pairs for the files in this format: @@ -493,76 +501,13 @@ object VideoDownloadManager { basePath: String? ): List>? { val base = basePathToFile(context, basePath) - val folder = base?.gotoDir(relativePath, false) + val folder = base?.gotoDirectory(relativePath, false) ?: return null + //if (folder.isDirectory() != false) return null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - return context.contentResolver?.getExistingFolderStartName(relativePath) - } else { -// val normalPath = -// "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( -// '/', -// File.separatorChar -// ) -// val folder = File(normalPath) - if (folder?.isDirectory == true) { - return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) } - } - } - return null -// } + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } } - @RequiresApi(Build.VERSION_CODES.Q) - private fun ContentResolver.getExistingDownloadUriOrNullQ( - relativePath: String, - displayName: String - ): Uri? { - try { - val projection = arrayOf( - MediaStore.MediaColumns._ID, - //MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only) - //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only) - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath' AND " + "${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'" - - val result = this.query( - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), - projection, selection, null, null - ) - - result.use { c -> - if (c != null && c.count >= 1) { - c.moveToFirst().let { - val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - /* - val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/ - - return ContentUris.withAppendedId( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, id - ) - } - } - } - return null - } catch (e: Exception) { - logError(e) - return null - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - fun ContentResolver.getFileLength(fileUri: Uri): Long? { - return try { - this.openFileDescriptor(fileUri, "r") - .use { it?.statSize ?: 0 } - } catch (e: Exception) { - logError(e) - null - } - } data class CreateNotificationMetadata( val type: DownloadType, @@ -570,19 +515,35 @@ object VideoDownloadManager { val bytesTotal: Long, val hlsProgress: Long? = null, val hlsTotal: Long? = null, + val bytesPerSecond: Long ) data class StreamData( - val errorCode: Int, - val resume: Boolean? = null, - val fileLength: Long? = null, - val fileStream: OutputStream? = null, - ) + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } - /** - * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. - * */ + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + + + @Throws(IOException::class) fun setupStream( context: Context, name: String, @@ -590,408 +551,922 @@ object VideoDownloadManager { extension: String, tryResume: Boolean, ): StreamData { - val displayName = getDisplayName(name, extension) - val fileStream: OutputStream - val fileLength: Long - var resume = tryResume - val baseFile = context.getBasePath() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.first?.isDownloadDir() == true) { - val cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - - val currentExistingFile = - cr.getExistingDownloadUriOrNullQ( - folder ?: "", - displayName - ) // CURRENT FILE WITH THE SAME PATH - - fileLength = - if (currentExistingFile == null || !resume) 0 else (cr.getFileLength( - currentExistingFile - ) - ?: 0)// IF NOT RESUME THEN 0, OTHERWISE THE CURRENT FILE SIZE - - if (!resume && currentExistingFile != null) { // DELETE FILE IF FILE EXITS AND NOT RESUME - val rowsDeleted = context.contentResolver.delete(currentExistingFile, null, null) - if (rowsDeleted < 1) { - println("ERROR DELETING FILE!!!") - } - } - - var appendFile = false - val newFileUri = if (resume && currentExistingFile != null) { - appendFile = true - currentExistingFile - } else { - val contentUri = - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI - //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - val currentMimeType = when (extension) { - - // Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents - // downloading to /Downloads yet it works with null - - "vtt" -> null // "text/vtt" - "mp4" -> "video/mp4" - "srt" -> null // "application/x-subrip"//"text/plain" - else -> null - } - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, name) - if (currentMimeType != null) - put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, folder) - } - - cr.insert( - contentUri, - newFile - ) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - } - - fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else "")) - ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - } else { - val subDir = baseFile.first?.gotoDir(folder) - val rFile = subDir?.findFile(displayName) - if (rFile?.exists() != true) { - fileLength = 0 - if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) - } else { - if (resume) { - fileLength = rFile.size() - } else { - fileLength = 0 - if (!rFile.delete()) return StreamData(ERROR_DELETING_FILE) - if (subDir.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) - } - } - fileStream = (subDir.findFile(displayName) - ?: subDir.createFile(displayName))!!.openOutputStream() -// fileStream = FileOutputStream(rFile, false) - if (fileLength == 0L) resume = false - } - return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) + return setupStream( + context.getBasePath().first ?: getDefaultDir(context) + ?: throw IOException("Bad config"), + name, + folder, + extension, + tryResume + ) } - fun downloadThing( - context: Context, - link: IDownloadableMinimum, + /** + * Sets up the appropriate file and creates a data stream from the file. + * Used for initializing downloads. + * */ + @Throws(IOException::class) + fun setupStream( + baseFile: SafeFile, name: String, folder: String?, extension: String, tryResume: Boolean, - parentId: Int?, - createNotificationCallback: (CreateNotificationMetadata) -> Unit, - ): Int { - if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { - return ERROR_UNKNOWN - } - - val basePath = context.getBasePath() - + ): StreamData { val displayName = getDisplayName(name, extension) - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath( - folder - ) else folder - fun deleteFile(): Int { - return delete(context, name, relativePath, extension, parentId, basePath.first) - } + val subDir = baseFile.gotoDirectoryOrThrow(folder) + val foundFile = subDir.findFile(displayName) - val stream = setupStream(context, name, relativePath, extension, tryResume) - if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode - - val resume = stream.resume!! - val fileStream = stream.fileStream!! - val fileLength = stream.fileLength!! - - // CONNECT - val connection: URLConnection = - URL(link.url.replace(" ", "%20")).openConnection() // IDK OLD PHONES BE WACK - - // SET CONNECTION SETTINGS - connection.connectTimeout = 10000 - connection.setRequestProperty("Accept-Encoding", "identity") - connection.setRequestProperty("user-agent", USER_AGENT) - if (link.referer.isNotEmpty()) connection.setRequestProperty("referer", link.referer) - - // extra stuff - connection.setRequestProperty( - "sec-ch-ua", - "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"" - ) - - connection.setRequestProperty("sec-ch-ua-mobile", "?0") - connection.setRequestProperty("accept", "*/*") - // dataSource.setRequestProperty("Sec-Fetch-Site", "none") //same-site - connection.setRequestProperty("sec-fetch-user", "?1") - connection.setRequestProperty("sec-fetch-mode", "navigate") - connection.setRequestProperty("sec-fetch-dest", "video") - link.headers.entries.forEach { - connection.setRequestProperty(it.key, it.value) - } - - if (resume) - connection.setRequestProperty("Range", "bytes=${fileLength}-") - val resumeLength = (if (resume) fileLength else 0) - - // ON CONNECTION - connection.connect() - - val contentLength = try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // fuck android - connection.contentLengthLong + val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) { + subDir.createFileOrThrow(displayName) to 0L + } else { + if (tryResume) { + foundFile to foundFile.lengthOrThrow() } else { - connection.getHeaderField("content-length").toLongOrNull() - ?: connection.contentLength.toLong() + foundFile.deleteOrThrow() + subDir.createFileOrThrow(displayName) to 0L } - } catch (e: Exception) { - logError(e) - 0L - } - val bytesTotal = contentLength + resumeLength - - if (extension == "mp4" && bytesTotal < 5000000) return ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG - - parentId?.let { - setKey( - KEY_DOWNLOAD_INFO, - it.toString(), - DownloadedFileInfo( - bytesTotal, - relativePath ?: "", - displayName, - basePath = basePath.second - ) - ) } - // Could use connection.contentType for mime types when creating the file, - // however file is already created and players don't go of file type + return StreamData(fileLength, file) + } - // https://stackoverflow.com/questions/23714383/what-are-all-the-possible-values-for-http-content-type-header - // might receive application/octet-stream - /*if (!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) { - return ERROR_WRONG_CONTENT // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE - }*/ + /** This class handles the notifications, as well as the relevant key */ + data class DownloadMetaData( + private val id: Int?, + var bytesDownloaded: Long = 0, + var bytesWritten: Long = 0, - // READ DATA FROM CONNECTION - val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream) - val buffer = ByteArray(1024) - var count: Int - var bytesDownloaded = resumeLength + var totalBytes: Long? = null, - var isPaused = false - var isStopped = false - var isDone = false - var isFailed = false + // notification metadata + private var lastUpdatedMs: Long = 0, + private var lastDownloadedBytes: Long = 0, + private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, - // TO NOT REUSE CODE - fun updateNotification() { - val type = when { - isDone -> DownloadType.IsDone - isStopped -> DownloadType.IsStopped - isFailed -> DownloadType.IsFailed - isPaused -> DownloadType.IsPaused - else -> DownloadType.IsDownloading - } + private var internalType: DownloadType = DownloadType.IsPending, - parentId?.let { id -> - try { - downloadStatus[id] = type - downloadStatusEvent.invoke(Pair(id, type)) - downloadProgressEvent.invoke(Triple(id, bytesDownloaded, bytesTotal)) - } catch (e: Exception) { - // IDK MIGHT ERROR - } - } + // how many segments that we have downloaded + var hlsProgress: Int = 0, + // how many segments that exist + var hlsTotal: Int? = null, + // this is how many segments that has been written to the file + // will always be <= hlsProgress as we may keep some in a buffer + var hlsWrittenProgress: Int = 0, - createNotificationCallback.invoke( - CreateNotificationMetadata( - type, - bytesDownloaded, - bytesTotal - ) - ) - /*createNotification( - context, - source, - link.name, - ep, - type, - bytesDownloaded, - bytesTotal - )*/ + // this is used for copy with metadata on how much we have downloaded for setKey + private var downloadFileInfoTemplate: DownloadedFileInfo? = null + ) : Closeable { + fun setResumeLength(length: Long) { + bytesDownloaded = length + bytesWritten = length + lastDownloadedBytes = length } - val downloadEventListener = { event: Pair -> - if (event.first == parentId) { + val approxTotalBytes: Long + get() = totalBytes ?: hlsTotal?.let { total -> + (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() + } ?: bytesDownloaded + + private val isHLS get() = hlsTotal != null + + private var stopListener: (() -> Unit)? = null + + /** on cancel button pressed or failed invoke this once and only once */ + fun setOnStop(callback: (() -> Unit)) { + stopListener = callback + } + + fun removeStopListener() { + stopListener = null + } + + private val downloadEventListener = { event: Pair -> + if (event.first == id) { when (event.second) { DownloadActionType.Pause -> { - isPaused = true; updateNotification() + type = DownloadType.IsPaused } + DownloadActionType.Stop -> { - isStopped = true; updateNotification() + type = DownloadType.IsStopped removeKey(KEY_RESUME_PACKAGES, event.first.toString()) saveQueue() + stopListener?.invoke() + stopListener = null } + DownloadActionType.Resume -> { - isPaused = false; updateNotification() + type = DownloadType.IsDownloading } } } } - if (parentId != null) - downloadEvent += downloadEventListener - - // UPDATE DOWNLOAD NOTIFICATION - val notificationCoroutine = main { - while (true) { - if (!isPaused) { - updateNotification() - } - for (i in 1..10) { - delay(100) - } + private fun updateFileInfo() { + if (id == null) return + downloadFileInfoTemplate?.let { template -> + setKey( + KEY_DOWNLOAD_INFO, + id.toString(), + template.copy( + totalBytes = approxTotalBytes, + extraInfo = if (isHLS) hlsWrittenProgress.toString() else null + ) + ) } } - // THE REAL READ - try { - while (true) { - count = connectionInputStream.read(buffer) - if (count < 0) break - bytesDownloaded += count - // downloadProgressEvent.invoke(Pair(id, bytesDownloaded)) // Updates too much for any UI to keep up with - while (isPaused) { - sleep(100) - if (isStopped) { - break - } - } - if (isStopped) { - break - } - fileStream.write(buffer, 0, count) - } - } catch (e: Exception) { - logError(e) - isFailed = true - updateNotification() + fun setDownloadFileInfoTemplate(template: DownloadedFileInfo) { + downloadFileInfoTemplate = template + updateFileInfo() } - // REMOVE AND EXIT ALL - fileStream.close() - connectionInputStream.close() - notificationCoroutine.cancel() + init { + if (id != null) { + downloadEvent += downloadEventListener + } + } - try { - if (parentId != null) + override fun close() { + // as we may need to resume hls downloads, we save the current written index + if (isHLS || totalBytes == null) { + updateFileInfo() + } + if (id != null) { downloadEvent -= downloadEventListener - } catch (e: Exception) { - logError(e) + downloadStatus -= id + } + stopListener = null } - try { - parentId?.let { - downloadStatus.remove(it) + var type + get() = internalType + set(value) { + internalType = value + notify() } - } catch (e: Exception) { - // IDK MIGHT ERROR + + fun onDelete() { + bytesDownloaded = 0 + hlsWrittenProgress = 0 + hlsProgress = 0 + if (id != null) + downloadDeleteEvent(id) + + //internalType = DownloadType.IsStopped + notify() } - // RETURN MESSAGE - return when { - isFailed -> { - parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } - ERROR_CONNECTION_ERROR - } - isStopped -> { - parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } - deleteFile() - } - else -> { - parentId?.let { id -> - downloadProgressEvent.invoke( - Triple( - id, + companion object { + const val UPDATE_RATE_MS: Long = 1000L + } + + @JvmName("DownloadMetaDataNotify") + private fun notify() { + // max 10 sec between notifications, min 0.1s, this is to stop div by zero + val dt = (System.currentTimeMillis() - lastUpdatedMs).coerceIn(100, 10000) + + val bytesPerSecond = + ((bytesDownloaded - lastDownloadedBytes) * 1000L) / dt + + lastDownloadedBytes = bytesDownloaded + lastUpdatedMs = System.currentTimeMillis() + try { + val bytes = approxTotalBytes + + // notification creation + if (isHLS) { + createNotificationCallback( + CreateNotificationMetadata( + internalType, bytesDownloaded, - bytesTotal + bytes, + hlsTotal = hlsTotal?.toLong(), + hlsProgress = hlsProgress.toLong(), + bytesPerSecond = bytesPerSecond + ) + ) + } else { + createNotificationCallback( + CreateNotificationMetadata( + internalType, + bytesDownloaded, + bytes, + bytesPerSecond = bytesPerSecond ) ) } - isDone = true - updateNotification() - SUCCESS_DOWNLOAD_DONE + + // as hls has an approx file size we want to update this metadata + if (isHLS) { + updateFileInfo() + } + + if (internalType == DownloadType.IsStopped || internalType == DownloadType.IsFailed) { + stopListener?.invoke() + stopListener = null + } + + // push all events, this *should* not crash, TODO MUTEX? + if (id != null) { + downloadStatus[id] = type + downloadProgressEvent(Triple(id, bytesDownloaded, bytes)) + downloadStatusEvent(id to type) + } + } catch (t: Throwable) { + logError(t) + if (BuildConfig.DEBUG) { + throw t + } + } + } + + private fun checkNotification() { + if (lastUpdatedMs + UPDATE_RATE_MS > System.currentTimeMillis()) return + notify() + } + + + /** adds the length and pushes a notification if necessary */ + fun addBytes(length: Long) { + bytesDownloaded += length + // we don't want to update the notification after it is paused, + // download progress may not stop directly when we "pause" it + if (type == DownloadType.IsDownloading) checkNotification() + } + + fun addBytesWritten(length: Long) { + bytesWritten += length + } + + /** adds the length + hsl progress and pushes a notification if necessary */ + fun addSegment(length: Long) { + hlsProgress += 1 + addBytes(length) + } + + fun setWrittenSegment(segmentIndex: Int) { + hlsWrittenProgress = segmentIndex + 1 + // in case of abort we need to save every written progress + updateFileInfo() + } + } + + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } + + data class LazyStreamDownloadData( + private val url: String, + private val headers: Map, + private val referer: String, + /** This specifies where chunck i starts and ends, + * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} + * where out of bounds => bytes=${chuckStartByte[ i ]}- */ + private val chuckStartByte: LongArray, + val totalLength: Long?, + val downloadLength: Long?, + val chuckSize: Long, + val bufferSize: Int, + ) { + val size get() = chuckStartByte.size + + /** returns what byte it has downloaded, + * so start at 10 and download 4 bytes = return 14 + * + * the range is [startByte, endByte) to be able to do [a, b) [b, c) ect + * + * [a, null) will return inclusive to eof = [a, eof] + * + * throws an error if initial get request fails, can be specified as return startByte + * */ + @Throws + private suspend fun resolve( + startByte: Long, + endByte: Long?, + callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) + ): Long = withContext(Dispatchers.IO) { + var currentByte: Long = startByte + val stopAt = endByte ?: Long.MAX_VALUE + if (currentByte >= stopAt) return@withContext currentByte + + val request = app.get( + url, + headers = headers + mapOf( + // range header is inclusive so [startByte, endByte-1] = [startByte, endByte) + // if nothing at end the server will continue until eof + "Range" to "bytes=$startByte-" // ${endByte?.minus(1)?.toString() ?: "" } + ), + referer = referer, + verify = false + ) + val requestStream = request.body.byteStream() + + val buffer = ByteArray(bufferSize) + var read: Int + + try { + while (requestStream.read(buffer, 0, bufferSize).also { read = it } >= 0) { + val start = currentByte + currentByte += read.toLong() + + // this stops overflow + if (currentByte >= stopAt) { + callback(LazyStreamDownloadResponse(buffer, start, stopAt)) + break + } else { + callback(LazyStreamDownloadResponse(buffer, start, currentByte)) + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logError(t) + } finally { + requestStream.closeQuietly() + } + + return@withContext currentByte + } + + /** retries the resolve n times and returns true if successful */ + suspend fun resolveSafe( + index: Int, + retries: Int = 3, + callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) + ): Boolean { + var start = chuckStartByte.getOrNull(index) ?: return false + val end = chuckStartByte.getOrNull(index + 1) + + for (i in 0 until retries) { + try { + // in case + start = resolve(start, end, callback) + // no end defined, so we don't care exactly where it ended + if (end == null) return true + // we have download more or exactly what we needed + if (start >= end) return true + } catch (e: IllegalStateException) { + return false + } catch (e: CancellationException) { + return false + } catch (t: Throwable) { + continue + } + } + return false + } + + } + + @Throws + suspend fun streamLazy( + url: String, + headers: Map, + referer: String, + startByte: Long, + /** how many bytes every connection should be, by default it is 10 MiB */ + chuckSize: Long = (1 shl 20) * 10, + /** maximum bytes in the buffer that responds */ + bufferSize: Int = DEFAULT_BUFFER_SIZE, + /** how many bytes bytes it should require to use the parallel downloader instead, + * if we download a very small file we don't want it parallel */ + maximumSmallSize: Long = chuckSize * 2 + ): LazyStreamDownloadData { + // we don't want to make a separate connection for every 1kb + require(chuckSize > 1000) + + var contentLength = + app.head(url = url, headers = headers, referer = referer, verify = false).size + if (contentLength != null && contentLength <= 0) contentLength = null + + var downloadLength: Long? = null + var totalLength: Long? = null + + val ranges = if (contentLength == null || contentLength < maximumSmallSize) { + // is the equivalent of [startByte..EOF] as we don't know the size we can only do one + // connection + LongArray(1) { startByte } + } else { + downloadLength = contentLength - startByte + totalLength = contentLength + // div with ceiling as + // this makes the last part "unknown ending" and it will break at EOF + // so eg startByte = 0, downloadLength = 13, chuckSize = 10 + // = LongArray(2) { 0, 10 } = [0,10) + [10..EOF] + LongArray(((downloadLength + chuckSize - 1) / chuckSize).toInt()) { idx -> + startByte + idx * chuckSize + } + } + + return LazyStreamDownloadData( + url = url, + headers = headers, + referer = referer, + chuckStartByte = ranges, + downloadLength = downloadLength, + totalLength = totalLength, + chuckSize = chuckSize, + bufferSize = bufferSize + ) + } + + /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + private fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + private fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) } } } + private suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } - /** - * Guarantees a directory is present with the dir name (if createMissingDirectories is true). - * Works recursively when '/' is present. - * Will remove any file with the dir name if present and add directory. - * Will not work if the parent directory does not exist. - * - * @param directoryName if null will use the current path. - * @return UniFile / null if createMissingDirectories = false and folder is not found. - * */ - private fun UniFile.gotoDir( - directoryName: String?, - createMissingDirectories: Boolean = true - ): UniFile? { - - // May give this error on scoped storage. - // W/DocumentsContract: Failed to create document - // java.lang.IllegalArgumentException: Parent document isn't a directory - - // Not present in latest testing. - -// println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}") + /** download a file that consist of a single stream of data*/ + suspend fun downloadThing( + context: Context, + link: IDownloadableMinimum, + name: String, + folder: String, + extension: String, + tryResume: Boolean, + parentId: Int?, + createNotificationCallback: (CreateNotificationMetadata) -> Unit, + parallelConnections: Int = 3, + /** how many bytes a valid file must be in bytes, + * this should be different for subtitles and video */ + minimumSize: Long = 100 + ): DownloadStatus = withContext(Dispatchers.IO) { + if (parallelConnections < 1) { + return@withContext DOWNLOAD_INVALID_INPUT + } + var fileStream: OutputStream? = null + //var requestStream: InputStream? = null + val metadata = DownloadMetaData( + totalBytes = 0, + bytesDownloaded = 0, + createNotificationCallback = createNotificationCallback, + id = parentId, + ) try { - // Creates itself from parent if doesn't exist. - if (!this.exists() && createMissingDirectories && !this.name.isNullOrBlank()) { - if (this.parentFile != null) { - this.parentFile?.createDirectory(this.name) - } else if (this.filePath != null) { - UniFile.fromFile(File(this.filePath!!).parentFile)?.createDirectory(this.name) - } + // get the file path + val (baseFile, basePath) = context.getBasePath() + val displayName = getDisplayName(name, extension) + if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG + + // set up the download file + val stream = setupStream(baseFile, name, folder, extension, tryResume) + + fileStream = stream.open() + + metadata.setResumeLength(stream.startAt) + metadata.type = DownloadType.IsPending + + val items = streamLazy( + url = link.url.replace(" ", "%20"), + referer = link.referer, + startByte = stream.startAt, + headers = link.headers.appendAndDontOverride( + mapOf( + "Accept-Encoding" to "identity", + "accept" to "*/*", + "user-agent" to USER_AGENT, + "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + "sec-fetch-mode" to "navigate", + "sec-fetch-dest" to "video", + "sec-fetch-user" to "?1", + "sec-ch-ua-mobile" to "?0", + ) + ) + ) + + if (items.totalLength != null && items.totalLength < minimumSize) { + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + return@withContext DOWNLOAD_INVALID_INPUT } - val allDirectories = directoryName?.split("/") - return if (allDirectories?.size == 1 || allDirectories == null) { - val found = this.findFile(directoryName) - when { - directoryName.isNullOrBlank() -> this - found?.isDirectory == true -> found + metadata.totalBytes = items.totalLength + metadata.type = DownloadType.IsDownloading + metadata.setDownloadFileInfoTemplate( + DownloadedFileInfo( + totalBytes = metadata.approxTotalBytes, + relativePath = folder, + displayName = displayName, + basePath = basePath + ) + ) - !createMissingDirectories -> null - // Below creates directories - found?.isFile == true -> { - found.delete() - this.createDirectory(directoryName) + val currentMutex = Mutex() + val current = (0 until items.size).iterator() + + val fileMutex = Mutex() + // start to data + val pendingData: HashMap = + hashMapOf() + + val fileChecker = launch(Dispatchers.IO) { + while (isActive) { + if (stream.exists) { + delay(5000) + continue } - this.isDirectory -> this.createDirectory(directoryName) - else -> this.parentFile?.createDirectory(directoryName) + fileMutex.withLock { + metadata.type = DownloadType.IsStopped + } + break } - } else { - var currentDirectory = this - allDirectories.forEach { - // If the next directory is not found it returns the deepest directory possible. - val nextDir = currentDirectory.gotoDir(it, createMissingDirectories) - currentDirectory = nextDir ?: return null - } - currentDirectory } - } catch (e: Exception) { + + val jobs = (0 until parallelConnections).map { + launch(Dispatchers.IO) { + + // @downloadexplanation + // this may seem a bit complex but it more or less acts as a queue system + // imagine we do the downloading [0,3] and it response in the order 0,2,3,1 + // file: [_,_,_,_] queue: [_,_,_,_] Initial condition + // file: [X,_,_,_] queue: [_,_,_,_] + added 0 directly to file + // file: [X,_,_,_] queue: [_,_,X,_] + added 2 to queue + // file: [X,_,_,_] queue: [_,_,X,X] + added 3 to queue + // file: [X,X,_,_] queue: [_,_,X,X] + added 1 directly to file + // file: [X,X,X,X] queue: [_,_,_,_] write the queue and remove from it + + // note that this is a bit more complex compared to hsl as ever segment + // will return several bytearrays, and is therefore chained by the byte + // so every request has a front and back byte instead of an index + // this *requires* that no gap exist due because of resolve + val callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) = + callback@{ response -> + if (!isActive) return@callback + fileMutex.withLock { + // wait until not paused + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then throw + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed) { + this.cancel() + return@callback + } + + val responseSize = response.size + metadata.addBytes(response.size) + + if (response.startByte == metadata.bytesWritten) { + // if we are first in the queue then write it directly + fileStream.write( + response.bytes, + 0, + responseSize.toInt() + ) + metadata.addBytesWritten(responseSize) + } else { + // otherwise append to queue, we need to clone the bytes as they will be overridden otherwise + pendingData[response.startByte] = + response.copy(bytes = response.bytes.clone()) + } + + while (true) { + // remove the current queue start, so no possibility of + // while(true) { continue } in case size = 0, and removed extra + // garbage + val pending = pendingData.remove(metadata.bytesWritten) ?: break + + val size = pending.size + + fileStream.write( + pending.bytes, + 0, + size.toInt() + ) + metadata.addBytesWritten(size) + } + } + } + + // this will take up the first available job and resolve + while (true) { + if (!isActive) return@launch + fileMutex.withLock { + if (metadata.type == DownloadType.IsStopped + || metadata.type == DownloadType.IsFailed + ) return@launch + } + + // mutex just in case, we never want this to fail due to multithreading + val index = currentMutex.withLock { + if (!current.hasNext()) return@launch + current.nextInt() + } + + // in case something has gone wrong set to failed if the fail is not caused by + // user cancellation + if (!items.resolveSafe(index, callback = callback)) { + fileMutex.withLock { + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } + return@launch + } + } + } + } + + // fast stop as the jobs may be in a slow request + metadata.setOnStop { + jobs.cancel() + } + + jobs.join() + fileChecker.cancel() + + // jobs are finished so we don't want to stop them anymore + metadata.removeStopListener() + if (!stream.exists) metadata.type = DownloadType.IsStopped + + if (metadata.type == DownloadType.IsFailed) { + return@withContext DOWNLOAD_FAILED + } + + if (metadata.type == DownloadType.IsStopped) { + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + return@withContext DOWNLOAD_STOPPED + } + + // in case the head request lies about content-size, + // then we don't want shit output + if (metadata.bytesDownloaded < minimumSize) { + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + return@withContext DOWNLOAD_INVALID_INPUT + } + + metadata.type = DownloadType.IsDone + return@withContext DOWNLOAD_SUCCESS + } catch (e: IOException) { + // some sort of IO error, this should not happened + // we just rethrow it logError(e) - return null + throw e + } catch (t: Throwable) { + // some sort of network error, will error + + // note that when failing we don't want to delete the file, + // only user interaction has that power + metadata.type = DownloadType.IsFailed + return@withContext DOWNLOAD_FAILED + } finally { + fileStream?.closeQuietly() + //requestStream?.closeQuietly() + metadata.close() + } + } + + private suspend fun downloadHLS( + context: Context, + link: ExtractorLink, + name: String, + folder: String, + parentId: Int?, + startIndex: Int?, + createNotificationCallback: (CreateNotificationMetadata) -> Unit, + parallelConnections: Int = 3 + ): DownloadStatus = withContext(Dispatchers.IO) { + if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT + + val metadata = DownloadMetaData( + createNotificationCallback = createNotificationCallback, + id = parentId + ) + var fileStream: OutputStream? = null + try { + val extension = "mp4" + + // the start .ts index + var startAt = startIndex ?: 0 + + // set up the file data + val (baseFile, basePath) = context.getBasePath() + if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG + + val displayName = getDisplayName(name, extension) + val stream = + setupStream(baseFile, name, folder, extension, startAt > 0) + + if (!stream.resume) startAt = 0 + fileStream = stream.open() + + // push the metadata + metadata.setResumeLength(stream.startAt) + metadata.hlsProgress = startAt + metadata.type = DownloadType.IsPending + metadata.setDownloadFileInfoTemplate( + DownloadedFileInfo( + totalBytes = 0, + relativePath = folder, + displayName = displayName, + basePath = basePath + ) + ) + + // do the initial get request to fetch the segments + val m3u8 = M3u8Helper.M3u8Stream( + link.url, link.quality, link.headers.appendAndDontOverride( + mapOf( + "Accept-Encoding" to "identity", + "accept" to "*/*", + "user-agent" to USER_AGENT, + ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() + ) + ) + + val items = M3u8Helper2.hslLazy(listOf(m3u8)) + + metadata.hlsTotal = items.size + metadata.type = DownloadType.IsDownloading + + val currentMutex = Mutex() + val current = (startAt until items.size).iterator() + + val fileMutex = Mutex() + val pendingData: HashMap = hashMapOf() + + val fileChecker = launch(Dispatchers.IO) { + while (isActive) { + if (stream.exists) { + delay(5000) + continue + } + fileMutex.withLock { + metadata.type = DownloadType.IsStopped + } + break + } + } + + // see @downloadexplanation for explanation of this download strategy, + // this keeps all jobs working at all times, + // does several connections in parallel instead of a regular for loop to improve + // download speed + val jobs = (0 until parallelConnections).map { + launch(Dispatchers.IO) { + while (true) { + if (!isActive) return@launch + fileMutex.withLock { + if (metadata.type == DownloadType.IsStopped + || metadata.type == DownloadType.IsFailed + ) return@launch + } + + // mutex just in case, we never want this to fail due to multithreading + val index = currentMutex.withLock { + if (!current.hasNext()) return@launch + current.nextInt() + } + + // in case something has gone wrong set to failed if the fail is not caused by + // user cancellation + val bytes = items.resolveLinkSafe(index) ?: run { + fileMutex.withLock { + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } + return@launch + } + + try { + fileMutex.lock() + // user pause + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch + + val segmentLength = bytes.size.toLong() + // send notification, no matter the actual write order + metadata.addSegment(segmentLength) + + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) + + metadata.addBytesWritten(segmentLength) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } + + // write the cached bytes submitted by other threads + while (true) { + val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() + + fileStream.write(cache) + + metadata.addBytesWritten(cacheLength) + metadata.setWrittenSegment(metadata.hlsWrittenProgress) + } + } catch (t: Throwable) { + // this is in case of write fail + logError(t) + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } finally { + try { + // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling + fileMutex.unlock() + } catch (t: Throwable) { + logError(t) + } + } + } + } + } + + // fast stop as the jobs may be in a slow request + metadata.setOnStop { + jobs.cancel() + } + + jobs.join() + fileChecker.cancel() + + metadata.removeStopListener() + + if (!stream.exists) metadata.type = DownloadType.IsStopped + + if (metadata.type == DownloadType.IsFailed) { + return@withContext DOWNLOAD_FAILED + } + + if (metadata.type == DownloadType.IsStopped) { + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + return@withContext DOWNLOAD_STOPPED + } + + metadata.type = DownloadType.IsDone + return@withContext DOWNLOAD_SUCCESS + } catch (t: Throwable) { + logError(t) + metadata.type = DownloadType.IsFailed + return@withContext DOWNLOAD_FAILED + } finally { + fileStream?.closeQuietly() + metadata.close() } } @@ -1006,21 +1481,10 @@ object VideoDownloadManager { * As of writing UniFile is used for everything but download directory on scoped storage. * Special ContentResolver fuckery is needed for that as UniFile doesn't work. * */ - fun getDownloadDir(): UniFile? { + fun getDefaultDir(context: Context): SafeFile? { // See https://www.py4u.net/discuss/614761 - return UniFile.fromFile( - File( - Environment.getExternalStorageDirectory().absolutePath + File.separatorChar + - Environment.DIRECTORY_DOWNLOADS - ) - ) - } - - @Deprecated("TODO fix UniFile to work with download directory.") - private fun getRelativePath(folder: String?): String { - return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace( - '/', - File.separatorChar + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads ) } @@ -1028,11 +1492,11 @@ object VideoDownloadManager { * Turns a string to an UniFile. Used for stored string paths such as settings. * Should only be used to get a download path. * */ - private fun basePathToFile(context: Context, path: String?): UniFile? { + private fun basePathToFile(context: Context, path: String?): SafeFile? { return when { - path.isNullOrBlank() -> getDownloadDir() - path.startsWith("content://") -> UniFile.fromUri(context, path.toUri()) - else -> UniFile.fromFile(File(path)) + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFile(context, File(path)) } } @@ -1041,300 +1505,12 @@ object VideoDownloadManager { * Returns the file and a string to be stored for future file retrieval. * UniFile.filePath is not sufficient for storage. * */ - fun Context.getBasePath(): Pair { + fun Context.getBasePath(): Pair { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) return basePathToFile(this, basePathSetting) to basePathSetting } - fun UniFile?.isDownloadDir(): Boolean { - return this != null && this.filePath == getDownloadDir()?.filePath - } - - private fun delete( - context: Context, - name: String, - folder: String?, - extension: String, - parentId: Int?, - basePath: UniFile? - ): Int { - val displayName = getDisplayName(name, extension) - - // delete all subtitle files - if (extension == "mp4") { - try { - delete(context, name, folder, "vtt", parentId, basePath) - delete(context, name, folder, "srt", parentId, basePath) - } catch (e: Exception) { - logError(e) - } - } - - // If scoped storage and using download dir (not accessible with UniFile) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.isDownloadDir()) { - val relativePath = getRelativePath(folder) - val lastContent = - context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) - if (lastContent != null) { - context.contentResolver.delete(lastContent, null, null) - } - } else { - val dir = basePath?.gotoDir(folder) - val file = dir?.findFile(displayName) - val success = file?.delete() - if (success != true) return ERROR_DELETING_FILE else { - // Cleans up empty directory - if (dir.listFiles()?.isEmpty() == true) dir.delete() - } -// } - parentId?.let { - downloadDeleteEvent.invoke(parentId) - } - } - return SUCCESS_STOPPED - } - - private fun downloadHLS( - context: Context, - link: ExtractorLink, - name: String, - folder: String?, - parentId: Int?, - startIndex: Int?, - createNotificationCallback: (CreateNotificationMetadata) -> Unit - ): Int { - val extension = "mp4" - fun logcatPrint(vararg items: Any?) { - items.forEach { - println("[HLS]: $it") - } - } - - val m3u8Helper = M3u8Helper() - logcatPrint("initialised the HLS downloader.") - - val m3u8 = M3u8Helper.M3u8Stream( - link.url, link.quality, mapOf("referer" to link.referer) - ) - - var realIndex = startIndex ?: 0 - val basePath = context.getBasePath() - - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath( - folder - ) else folder - - val stream = setupStream(context, name, relativePath, extension, realIndex > 0) - if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode - - if (!stream.resume!!) realIndex = 0 - val fileLengthAdd = stream.fileLength!! - val tsIterator = runBlocking { - m3u8Helper.hlsYield(listOf(m3u8), realIndex) - } - - val displayName = getDisplayName(name, extension) - - val fileStream = stream.fileStream!! - - val firstTs = tsIterator.next() - - var isDone = false - var isFailed = false - var isPaused = false - var bytesDownloaded = firstTs.bytes.size.toLong() + fileLengthAdd - var tsProgress = 1L + realIndex - val totalTs = firstTs.totalTs.toLong() - - fun deleteFile(): Int { - return delete(context, name, relativePath, extension, parentId, basePath.first) - } - /* - Most of the auto generated m3u8 out there have TS of the same size. - And only the last TS might have a different size. - - But oh well, in cases of handmade m3u8 streams this will go all over the place ¯\_(ツ)_/¯ - So ya, this calculates an estimate of how many bytes the file is going to be. - - > (bytesDownloaded/tsProgress)*totalTs - */ - - fun updateInfo() { - parentId?.let { - setKey( - KEY_DOWNLOAD_INFO, - it.toString(), - DownloadedFileInfo( - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - relativePath ?: "", - displayName, - tsProgress.toString(), - basePath = basePath.second - ) - ) - } - } - - updateInfo() - - fun updateNotification() { - val type = when { - isDone -> DownloadType.IsDone - isFailed -> DownloadType.IsFailed - isPaused -> DownloadType.IsPaused - else -> DownloadType.IsDownloading - } - - parentId?.let { id -> - try { - downloadStatus[id] = type - downloadStatusEvent.invoke(Pair(id, type)) - downloadProgressEvent.invoke( - Triple( - id, - bytesDownloaded, - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - ) - ) - } catch (e: Exception) { - // IDK MIGHT ERROR - } - } - - createNotificationCallback.invoke( - CreateNotificationMetadata( - type, - bytesDownloaded, - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - tsProgress, - totalTs - ) - ) - } - - fun stopIfError(ts: M3u8Helper.HlsDownloadData): Int? { - if (ts.errored || ts.bytes.isEmpty()) { - val error: Int = if (!ts.errored) { - logcatPrint("Error: No stream was found.") - ERROR_UNKNOWN - } else { - logcatPrint("Error: Failed to fetch data.") - ERROR_CONNECTION_ERROR - } - isFailed = true - fileStream.close() - deleteFile() - updateNotification() - return error - } - return null - } - - val notificationCoroutine = main { - while (true) { - if (!isDone) { - updateNotification() - } - for (i in 1..10) { - delay(100) - } - } - } - - val downloadEventListener = { event: Pair -> - if (event.first == parentId) { - when (event.second) { - DownloadActionType.Stop -> { - isFailed = true - } - DownloadActionType.Pause -> { - isPaused = - true // Pausing is not supported since well...I need to know the index of the ts it was paused at - // it may be possible to store it in a variable, but when the app restarts it will be lost - } - DownloadActionType.Resume -> { - isPaused = false - } - } - updateNotification() - } - } - - fun closeAll() { - try { - if (parentId != null) - downloadEvent -= downloadEventListener - } catch (e: Exception) { - logError(e) - } - try { - parentId?.let { - downloadStatus.remove(it) - } - } catch (e: Exception) { - logError(e) - // IDK MIGHT ERROR - } - notificationCoroutine.cancel() - } - - stopIfError(firstTs).let { - if (it != null) { - closeAll() - return it - } - } - - if (parentId != null) - downloadEvent += downloadEventListener - - fileStream.write(firstTs.bytes) - - fun onFailed() { - fileStream.close() - deleteFile() - updateNotification() - closeAll() - } - - for (ts in tsIterator) { - while (isPaused) { - if (isFailed) { - onFailed() - return SUCCESS_STOPPED - } - sleep(100) - } - - if (isFailed) { - onFailed() - return SUCCESS_STOPPED - } - - stopIfError(ts).let { - if (it != null) { - closeAll() - return it - } - } - - fileStream.write(ts.bytes) - tsProgress = ts.currentIndex.toLong() - bytesDownloaded += ts.bytes.size.toLong() - logcatPrint("Download progress ${((tsProgress.toFloat() / totalTs.toFloat()) * 100).roundToInt()}%") - updateInfo() - } - isDone = true - fileStream.close() - updateNotification() - - closeAll() - updateInfo() - return SUCCESS_DOWNLOAD_DONE - } - fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { return getFileName(context, metadata.name, metadata.episode, metadata.season) } @@ -1367,7 +1543,7 @@ object VideoDownloadManager { ) } - private fun downloadSingleEpisode( + private suspend fun downloadSingleEpisode( context: Context, source: String?, folder: String?, @@ -1375,7 +1551,12 @@ object VideoDownloadManager { link: ExtractorLink, notificationCallback: (Int, Notification) -> Unit, tryResume: Boolean = false, - ): Int { + ): DownloadStatus { + // no support for these file formats + if (link.type == ExtractorLinkType.MAGNET || link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.DASH) { + return DOWNLOAD_INVALID_INPUT + } + val name = getFileName(context, ep) // Make sure this is cancelled when download is done or cancelled. @@ -1385,223 +1566,267 @@ object VideoDownloadManager { } } - if (link.isM3u8 || URI(link.url).path.endsWith(".m3u8")) { - val startIndex = if (tryResume) { - context.getKey( - KEY_DOWNLOAD_INFO, - ep.id.toString(), - null - )?.extraInfo?.toIntOrNull() - } else null - return downloadHLS(context, link, name, folder, ep.id, startIndex) { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback, - meta.hlsProgress, - meta.hlsTotal - ) - } - }.also { extractorJob.cancel() } - } - - return normalSafeApiCall { - downloadThing(context, link, name, folder, "mp4", tryResume, ep.id) { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback - ) - } - } - }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN - } - - fun downloadCheck( - context: Context, notificationCallback: (Int, Notification) -> Unit, - ): Int? { - if (currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0) { - val pkg = downloadQueue.removeFirst() - val item = pkg.item - val id = item.ep.id - if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(Pair(id, DownloadActionType.Resume)) - /** ID needs to be returned to the work-manager to properly await notification */ - return id - } - - currentDownloads.add(id) - + val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - val connectionResult = withContext(Dispatchers.IO) { - normalSafeApiCall { - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ).also { println("Single episode finished with return code: $it") } - } - } - if (connectionResult != null && connectionResult > 0) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } - } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the workmanager - downloadCheckUsingWorker(context) - } + createNotification( + context, + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback, + meta.hlsProgress, + meta.hlsTotal, + meta.bytesPerSecond + ) } } - return null + + try { + when (link.type) { + ExtractorLinkType.M3U8 -> { + val startIndex = if (tryResume) { + context.getKey( + KEY_DOWNLOAD_INFO, + ep.id.toString(), + null + )?.extraInfo?.toIntOrNull() + } else null + + return downloadHLS( + context, + link, + name, + folder ?: "", + ep.id, + startIndex, + callback, parallelConnections = maxConcurrentConnections + ) + } + + ExtractorLinkType.VIDEO -> { + return downloadThing( + context, + link, + name, + folder ?: "", + "mp4", + tryResume, + ep.id, + callback, + parallelConnections = maxConcurrentConnections, + /** We require at least 10 MB video files */ + minimumSize = (1 shl 20) * 10 + ) + } + + else -> throw IllegalArgumentException("unsuported download type") + } + } catch (t: Throwable) { + return DOWNLOAD_FAILED + } finally { + extractorJob.cancel() + } } - fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { - val res = getDownloadFileInfo(context, id) - if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) - return res + suspend fun downloadCheck( + context: Context, notificationCallback: (Int, Notification) -> Unit, + ) { + if (!(currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0)) return + + val pkg = downloadQueue.removeFirst() + val item = pkg.item + val id = item.ep.id + if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } + + currentDownloads.add(id) + try { + for (index in (pkg.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = pkg.linkIndex == index + + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) + + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) + + if (connectionResult.retrySame) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true + ) + } + + if (connectionResult.success) { // SUCCESS + removeKey(KEY_RESUME_PACKAGES, id.toString()) + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) + break + } + } + } catch (e: Exception) { + logError(e) + } finally { + currentDownloads.remove(id) + // Because otherwise notifications will not get caught by the work manager + downloadCheckUsingWorker(context) + } + + // return id } - private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { + /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { + val res = getDownloadFileInfo(context, id) + if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) + return res + } + */ + fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = + getDownloadFileInfo(context, id) + + private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath) + ?.findFile(displayName) + } + + private fun getDownloadFileInfo( + context: Context, + id: Int, + ): DownloadedFileInfoResult? { try { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null - val base = basePathToFile(context, info.basePath) + val file = info.toFile(context) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - val cr = context.contentResolver ?: return null - val fileUri = - cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) - ?: return null - val fileLength = cr.getFileLength(fileUri) ?: return null - if (fileLength == 0L) return null - return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri) - } else { - - val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) - -// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) -// val dFile = File(normalPath) - - if (file?.exists() != true) return null - - return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri) + // only delete the key if the file is not found + if (file == null || !file.existsOrThrow()) { + //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD + return null } + + return DownloadedFileInfoResult( + file.lengthOrThrow(), + info.totalBytes, + file.uriOrThrow() + ) } catch (e: Exception) { logError(e) return null } } - /** - * Gets the true download size as Scoped Storage sometimes wrongly returns 0. - * */ - fun UniFile.size(): Long { - val len = length() - return if (len <= 1) { - val inputStream = this.openInputStream() - return inputStream.available().toLong().also { inputStream.closeQuietly() } - } else { - len + fun deleteFilesAndUpdateSettings( + context: Context, + ids: Set, + scope: CoroutineScope, + onComplete: (Set) -> Unit = {} + ) { + scope.launchSafe(Dispatchers.IO) { + val deleteJobs = ids.map { id -> + async { + id to deleteFileAndUpdateSettings(context, id) + } + } + val results = deleteJobs.awaitAll() + + val (successfulResults, failedResults) = results.partition { it.second } + val successfulIds = successfulResults.map { it.first }.toSet() + + if (failedResults.isNotEmpty()) { + failedResults.forEach { (id, _) -> + // TODO show a toast if some failed? + Log.e("FileDeletion", "Failed to delete file with ID: $id") + } + } else { + Log.i("FileDeletion", "All files deleted successfully") + } + + onComplete.invoke(successfulIds) } } - fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { + private fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { val success = deleteFile(context, id) if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) return success } + /*private fun deleteFile( + context: Context, + folder: SafeFile?, + relativePath: String, + displayName: String + ): Boolean { + val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false + if (file.exists() == false) return true + return try { + file.delete() + } catch (e: Exception) { + logError(e) + (context.contentResolver?.delete(file.uri() ?: return true, null, null) + ?: return false) > 0 + } + }*/ + private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false - downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped)) - downloadDeleteEvent.invoke(id) - val base = basePathToFile(context, info.basePath) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - val cr = context.contentResolver ?: return false - val fileUri = - cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) - ?: return true // FILE NOT FOUND, ALREADY DELETED + val file = info.toFile(context) - return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0 - } else { - val file = base?.gotoDir(info.relativePath)?.findFile(info.displayName) -// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) -// val dFile = File(normalPath) - if (file?.exists() != true) return true - return try { - file.delete() - } catch (e: Exception) { - logError(e) - val cr = context.contentResolver - cr.delete(file.uri, null, null) > 0 - } - } + downloadEvent.invoke(id to DownloadActionType.Stop) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) + + val isFileDeleted = file?.delete() == true || file?.exists() == false + if (isFileDeleted) deleteMatchingSubtitles(context, info) + + return isFileDeleted } fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - fun downloadFromResume( + suspend fun downloadFromResume( context: Context, pkg: DownloadResumePackage, notificationCallback: (Int, Notification) -> Unit, setKey: Boolean = true ) { - if (!currentDownloads.any { it == pkg.item.ep.id }) { -// if (currentDownloads.size == maxConcurrentDownloads) { -// main { -//// showToast( // can be replaced with regular Toast -//// context, -//// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ -//// context.getString( -//// R.string.queued -//// ) -//// }", -//// Toast.LENGTH_SHORT -//// ) -// } -// } + if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { downloadQueue.addLast(pkg) downloadCheck(context, notificationCallback) if (setKey) saveQueue() + //ret } else { - downloadEvent.invoke( - Pair(pkg.item.ep.id, DownloadActionType.Resume) + downloadEvent( + pkg.item.ep.id to DownloadActionType.Resume ) + //null } } @@ -1612,7 +1837,7 @@ object VideoDownloadManager { .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } .toTypedArray() setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) - } catch (t : Throwable) { + } catch (t: Throwable) { logError(t) } } @@ -1627,7 +1852,7 @@ object VideoDownloadManager { return false }*/ - fun downloadEpisode( + suspend fun downloadEpisode( context: Context?, source: String?, folder: String?, @@ -1636,13 +1861,12 @@ object VideoDownloadManager { notificationCallback: (Int, Notification) -> Unit, ) { if (context == null) return - if (links.isNotEmpty()) { - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) - } + if (links.isEmpty()) return + downloadFromResume( + context, + DownloadResumePackage(DownloadItem(source, folder, ep, links), null), + notificationCallback + ) } /** Worker stuff */ @@ -1702,4 +1926,4 @@ object VideoDownloadManager { @JsonProperty("ep") val ep: DownloadEpisodeMetadata, @JsonProperty("links") val links: List ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt new file mode 100644 index 00000000..e7c36a87 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt @@ -0,0 +1,140 @@ +package com.lagradost.cloudstream3.utils.fcast + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdManager.ResolveListener +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.util.Log +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe + +class FcastManager { + private var nsdManager: NsdManager? = null + + // Used for receiver + private val registrationListenerTcp = DefaultRegistrationListener() + private fun getDeviceName(): String { + return "${Build.MANUFACTURER}-${Build.MODEL}" + } + + /** + * Start the fcast service + * @param registerReceiver If true will register the app as a compatible fcast receiver for discovery in other app + */ + fun init(context: Context, registerReceiver: Boolean) = ioSafe { + nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + val serviceType = "_fcast._tcp" + + if (registerReceiver) { + val serviceName = "$APP_PREFIX-${getDeviceName()}" + + val serviceInfo = NsdServiceInfo().apply { + this.serviceName = serviceName + this.serviceType = serviceType + this.port = TCP_PORT + } + + nsdManager?.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListenerTcp + ) + } + + nsdManager?.discoverServices( + serviceType, + NsdManager.PROTOCOL_DNS_SD, + DefaultDiscoveryListener() + ) + } + + fun stop() { + nsdManager?.unregisterService(registrationListenerTcp) + } + + inner class DefaultDiscoveryListener : NsdManager.DiscoveryListener { + val tag = "DiscoveryListener" + override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.d(tag, "Discovery failed: $serviceType, error code: $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.d(tag, "Stop discovery failed: $serviceType, error code: $errorCode") + } + + override fun onDiscoveryStarted(serviceType: String?) { + Log.d(tag, "Discovery started: $serviceType") + } + + override fun onDiscoveryStopped(serviceType: String?) { + Log.d(tag, "Discovery stopped: $serviceType") + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + nsdManager?.resolveService(serviceInfo, object : ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) + } + }) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + // May remove duplicates, but net and port is null here, preventing device specific identification + synchronized(_currentDevices) { + _currentDevices.removeAll { + it.rawName == serviceInfo.serviceName + } + } + + Log.d(tag, "Service lost: ${serviceInfo.serviceName}") + } + } + + companion object { + const val APP_PREFIX = "CloudStream" + private val _currentDevices: MutableList = mutableListOf() + val currentDevices: List = _currentDevices + + class DefaultRegistrationListener : NsdManager.RegistrationListener { + val tag = "DiscoveryService" + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + Log.d(tag, "Service registered: ${serviceInfo.serviceName}") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(tag, "Service registration failed: errorCode=$errorCode") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { + Log.d(tag, "Service unregistered: ${serviceInfo.serviceName}") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(tag, "Service unregistration failed: errorCode=$errorCode") + } + } + + const val TCP_PORT = 46899 + } +} + +class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { + val rawName: String = serviceInfo.serviceName + val host: String? = serviceInfo.host.hostAddress + val name = rawName.replace("-", " ") + host?.let { " $it" } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastSession.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastSession.kt new file mode 100644 index 00000000..1f33bca4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastSession.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream3.utils.fcast + +import android.util.Log +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.safefile.closeQuietly +import java.io.DataOutputStream +import java.net.Socket +import kotlin.jvm.Throws + +class FcastSession(private val hostAddress: String): AutoCloseable { + val tag = "FcastSession" + + private var socket: Socket? = null + @Throws + @WorkerThread + fun open(): Socket { + val socket = Socket(hostAddress, FcastManager.TCP_PORT) + this.socket = socket + return socket + } + + override fun close() { + socket?.closeQuietly() + socket = null + } + + @Throws + private fun acquireSocket(): Socket { + return socket ?: open() + } + + fun ping() { + sendMessage(Opcode.Ping, null) + } + + fun sendMessage(opcode: Opcode, message: T) { + ioSafe { + val socket = acquireSocket() + val outputStream = DataOutputStream(socket.getOutputStream()) + + val json = message?.toJson() + val content = json?.toByteArray() ?: ByteArray(0) + + // Little endian starting from 1 + // https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 + val size = content.size + 1 + + val sizeArray = ByteArray(4) { num -> + (size shr 8 * num and 0xff).toByte() + } + + Log.d(tag, "Sending message with size: $size, opcode: $opcode") + outputStream.write(sizeArray) + outputStream.write(ByteArray(1) { opcode.value }) + outputStream.write(content) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/Packets.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/Packets.kt new file mode 100644 index 00000000..61c00d6e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/Packets.kt @@ -0,0 +1,62 @@ +package com.lagradost.cloudstream3.utils.fcast + +// See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 +enum class Opcode(val value: Byte) { + None(0), + Play(1), + Pause(2), + Resume(3), + Stop(4), + Seek(5), + PlaybackUpdate(6), + VolumeUpdate(7), + SetVolume(8), + PlaybackError(9), + SetSpeed(10), + Version(11), + Ping(12), + Pong(13); +} + + +data class PlayMessage( + val container: String, + val url: String? = null, + val content: String? = null, + val time: Double? = null, + val speed: Double? = null, + val headers: Map? = null +) + +data class SeekMessage( + val time: Double +) + +data class PlaybackUpdateMessage( + val generationTime: Long, + val time: Double, + val duration: Double, + val state: Int, + val speed: Double +) + +data class VolumeUpdateMessage( + val generationTime: Long, + val volume: Double +) + +data class PlaybackErrorMessage( + val message: String +) + +data class SetSpeedMessage( + val speed: Double +) + +data class SetVolumeMessage( + val volume: Double +) + +data class VersionMessage( + val version: Long +) diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index d4725d53..2aea0b8d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -19,7 +19,7 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); + itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) t.recycle() } diff --git a/app/src/main/res/anim/rotate_around_center_point.xml b/app/src/main/res/anim/rotate_around_center_point.xml new file mode 100644 index 00000000..76e7b39b --- /dev/null +++ b/app/src/main/res/anim/rotate_around_center_point.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/button_selector_color.xml b/app/src/main/res/color/button_selector_color.xml new file mode 100644 index 00000000..9975946d --- /dev/null +++ b/app/src/main/res/color/button_selector_color.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/player_on_button_tv_attr.xml b/app/src/main/res/color/player_on_button_tv_attr.xml new file mode 100644 index 00000000..feb1eeb0 --- /dev/null +++ b/app/src/main/res/color/player_on_button_tv_attr.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/white_attr_20.xml b/app/src/main/res/color/white_attr_20.xml new file mode 100644 index 00000000..e0237df0 --- /dev/null +++ b/app/src/main/res/color/white_attr_20.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_help_outline_24.xml b/app/src/main/res/drawable/baseline_help_outline_24.xml new file mode 100644 index 00000000..3a72cda0 --- /dev/null +++ b/app/src/main/res/drawable/baseline_help_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_network_ping_24.xml b/app/src/main/res/drawable/baseline_network_ping_24.xml new file mode 100644 index 00000000..1caae667 --- /dev/null +++ b/app/src/main/res/drawable/baseline_network_ping_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_notifications_none_24.xml b/app/src/main/res/drawable/baseline_notifications_none_24.xml new file mode 100644 index 00000000..cf589c6d --- /dev/null +++ b/app/src/main/res/drawable/baseline_notifications_none_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_remove_24.xml b/app/src/main/res/drawable/baseline_remove_24.xml index 791a2f81..f4455598 100644 --- a/app/src/main/res/drawable/baseline_remove_24.xml +++ b/app/src/main/res/drawable/baseline_remove_24.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:tint="?attr/white"> diff --git a/app/src/main/res/drawable/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml new file mode 100644 index 00000000..100cb1fc --- /dev/null +++ b/app/src/main/res/drawable/baseline_stop_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_text_snippet_24.xml b/app/src/main/res/drawable/baseline_text_snippet_24.xml new file mode 100644 index 00000000..c1f3654b --- /dev/null +++ b/app/src/main/res/drawable/baseline_text_snippet_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/circle_shape_dotted.xml b/app/src/main/res/drawable/circle_shape_dotted.xml new file mode 100644 index 00000000..6ce2808c --- /dev/null +++ b/app/src/main/res/drawable/circle_shape_dotted.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_clockwise.xml b/app/src/main/res/drawable/circular_progress_bar_clockwise.xml new file mode 100644 index 00000000..a2e7f022 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_clockwise.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml b/app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml new file mode 100644 index 00000000..477e8db1 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_small_to_large.xml b/app/src/main/res/drawable/circular_progress_bar_small_to_large.xml new file mode 100644 index 00000000..eed44628 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_small_to_large.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml b/app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml new file mode 100644 index 00000000..f41eea84 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cloud_2_solid.xml b/app/src/main/res/drawable/cloud_2_solid.xml new file mode 100644 index 00000000..3810b4bf --- /dev/null +++ b/app/src/main/res/drawable/cloud_2_solid.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon_done.xml b/app/src/main/res/drawable/download_icon_done.xml new file mode 100644 index 00000000..a41ac14e --- /dev/null +++ b/app/src/main/res/drawable/download_icon_done.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon_error.xml b/app/src/main/res/drawable/download_icon_error.xml new file mode 100644 index 00000000..ef56f19a --- /dev/null +++ b/app/src/main/res/drawable/download_icon_error.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon_load.xml b/app/src/main/res/drawable/download_icon_load.xml new file mode 100644 index 00000000..bde9a160 --- /dev/null +++ b/app/src/main/res/drawable/download_icon_load.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon_pause.xml b/app/src/main/res/drawable/download_icon_pause.xml new file mode 100644 index 00000000..08455521 --- /dev/null +++ b/app/src/main/res/drawable/download_icon_pause.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/episodes_shadow.xml b/app/src/main/res/drawable/episodes_shadow.xml new file mode 100644 index 00000000..a77cbf25 --- /dev/null +++ b/app/src/main/res/drawable/episodes_shadow.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/example_qr.png b/app/src/main/res/drawable/example_qr.png new file mode 100644 index 00000000..764cb966 Binary files /dev/null and b/app/src/main/res/drawable/example_qr.png differ diff --git a/app/src/main/res/drawable/hourglass_24.xml b/app/src/main/res/drawable/hourglass_24.xml new file mode 100644 index 00000000..7bd1ebbd --- /dev/null +++ b/app/src/main/res/drawable/hourglass_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml index ebe459b2..dbda1cc0 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml @@ -3,6 +3,7 @@ android:viewportWidth="48" android:viewportHeight="48" android:tint="?attr/white" + android:autoMirrored="true" xmlns:android="http://schemas.android.com/apk/res/android"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml index 6c3197a6..516df382 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml index 2ec8c110..48ac45e7 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml @@ -3,6 +3,7 @@ android:viewportWidth="48" android:viewportHeight="48" android:tint="?attr/white" + android:autoMirrored="true" xmlns:android="http://schemas.android.com/apk/res/android"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml new file mode 100644 index 00000000..dba3e567 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_edit_24.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_equalizer_24.xml b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml new file mode 100644 index 00000000..cd20ad15 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_film_roll_24.xml b/app/src/main/res/drawable/ic_baseline_film_roll_24.xml new file mode 100644 index 00000000..941d936f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_film_roll_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml index 916c761c..b67188db 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_language_24.xml b/app/src/main/res/drawable/ic_baseline_language_24.xml index 1749952e..89b47937 100644 --- a/app/src/main/res/drawable/ic_baseline_language_24.xml +++ b/app/src/main/res/drawable/ic_baseline_language_24.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_more_vert_24.xml b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml index 249fe2a2..b6908e96 100644 --- a/app/src/main/res/drawable/ic_baseline_more_vert_24.xml +++ b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml b/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml index 2003bfe7..5d6045e7 100644 --- a/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml +++ b/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml b/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml new file mode 100644 index 00000000..2651015c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_replay_24.xml b/app/src/main/res/drawable/ic_baseline_replay_24.xml new file mode 100644 index 00000000..e247aa92 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_replay_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_restart_24.xml b/app/src/main/res/drawable/ic_baseline_restart_24.xml new file mode 100644 index 00000000..aed3a562 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_restart_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow.xml new file mode 100644 index 00000000..0326fbd4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_resume_arrow.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml new file mode 100644 index 00000000..fc533a0e --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml new file mode 100644 index 00000000..a8c43bbd --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml new file mode 100644 index 00000000..452c4dd9 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery.xml b/app/src/main/res/drawable/ic_battery.xml new file mode 100644 index 00000000..24d0a77f --- /dev/null +++ b/app/src/main/res/drawable/ic_battery.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml new file mode 100644 index 00000000..4b8964f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_fingerprint.xml b/app/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 00000000..5c96e5a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_network_stream.xml b/app/src/main/res/drawable/ic_network_stream.xml new file mode 100644 index 00000000..8e21fd25 --- /dev/null +++ b/app/src/main/res/drawable/ic_network_stream.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_outline_account_circle_24.xml b/app/src/main/res/drawable/ic_outline_account_circle_24.xml index cc564471..27c2d574 100644 --- a/app/src/main/res/drawable/ic_outline_account_circle_24.xml +++ b/app/src/main/res/drawable/ic_outline_account_circle_24.xml @@ -1,6 +1,13 @@ - - - + + + diff --git a/app/src/main/res/drawable/library_icon.xml b/app/src/main/res/drawable/library_icon.xml new file mode 100644 index 00000000..f62dceac --- /dev/null +++ b/app/src/main/res/drawable/library_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/netflix_skip_back.xml b/app/src/main/res/drawable/netflix_skip_back.xml index bb63e948..5ad9c1a1 100644 --- a/app/src/main/res/drawable/netflix_skip_back.xml +++ b/app/src/main/res/drawable/netflix_skip_back.xml @@ -1,23 +1,23 @@ + android:width="850.39dp" + android:height="850.39dp" + android:viewportWidth="850.39" + android:viewportHeight="850.39"> + android:fillColor="#00000000" + android:pathData="M143.05,279.28A317.41,317.41 0,0 0,106.3 428c0,176.13 142.77,318.9 318.9,318.9S744.09,604.16 744.09,428 601.32,109.14 425.2,109.14q-14.15,0 -28,1.2" + android:strokeWidth="45" + android:strokeColor="#fff" /> + android:fillColor="#fff" + android:pathData="M483.083,223.108l-111.666,-111.666l25.442,-25.442l111.666,111.666z" /> + android:fillColor="#fff" + android:pathData="M371.421,111.662l111.666,-111.666l25.442,25.442l-111.666,111.666z" /> + android:fillColor="#fff" + android:pathData="M398.087,223.272l-111.666,-111.666l25.442,-25.442l111.666,111.666z" /> + android:fillColor="#fff" + android:pathData="M286.427,111.826l111.666,-111.666l25.442,25.442l-111.666,111.666z" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_bookmark_add_24.xml b/app/src/main/res/drawable/outline_bookmark_add_24.xml new file mode 100644 index 00000000..a4e18af3 --- /dev/null +++ b/app/src/main/res/drawable/outline_bookmark_add_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/outline_card.xml b/app/src/main/res/drawable/outline_card.xml index 02116bb8..5716de45 100644 --- a/app/src/main/res/drawable/outline_card.xml +++ b/app/src/main/res/drawable/outline_card.xml @@ -1,21 +1,20 @@ + android:color="@android:color/white"> - + android:width="2dp" + android:color="@android:color/white" /> + - + - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_forced.xml b/app/src/main/res/drawable/outline_drawable_forced.xml new file mode 100644 index 00000000..16eba83c --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_forced.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_forced_round.xml b/app/src/main/res/drawable/outline_drawable_forced_round.xml new file mode 100644 index 00000000..7736f088 --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_forced_round.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_less.xml b/app/src/main/res/drawable/outline_drawable_less.xml index 0b641074..aa3a8d0d 100644 --- a/app/src/main/res/drawable/outline_drawable_less.xml +++ b/app/src/main/res/drawable/outline_drawable_less.xml @@ -1,4 +1,5 @@ - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_button_tv_attr.xml b/app/src/main/res/drawable/player_button_tv_attr.xml new file mode 100644 index 00000000..ed83887d --- /dev/null +++ b/app/src/main/res/drawable/player_button_tv_attr.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml b/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml new file mode 100644 index 00000000..0dd8c256 --- /dev/null +++ b/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/preview_seekbar_24.xml b/app/src/main/res/drawable/preview_seekbar_24.xml new file mode 100644 index 00000000..657f6247 --- /dev/null +++ b/app/src/main/res/drawable/preview_seekbar_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/profile_bg_blue.jpg b/app/src/main/res/drawable/profile_bg_blue.jpg new file mode 100644 index 00000000..e573439b Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_blue.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_dark_blue.jpg b/app/src/main/res/drawable/profile_bg_dark_blue.jpg new file mode 100644 index 00000000..d59e4888 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_dark_blue.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_orange.jpg b/app/src/main/res/drawable/profile_bg_orange.jpg new file mode 100644 index 00000000..a97e7179 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_orange.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_pink.jpg b/app/src/main/res/drawable/profile_bg_pink.jpg new file mode 100644 index 00000000..9d4940f0 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_pink.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_purple.jpg b/app/src/main/res/drawable/profile_bg_purple.jpg new file mode 100644 index 00000000..15723dba Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_purple.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_red.jpg b/app/src/main/res/drawable/profile_bg_red.jpg new file mode 100644 index 00000000..6a27ff31 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_red.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_teal.jpg b/app/src/main/res/drawable/profile_bg_teal.jpg new file mode 100644 index 00000000..93236650 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_teal.jpg differ diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml new file mode 100644 index 00000000..b85ace8e --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/screen_rotation.xml b/app/src/main/res/drawable/screen_rotation.xml new file mode 100644 index 00000000..da0ac0fd --- /dev/null +++ b/app/src/main/res/drawable/screen_rotation.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/simkl_logo.xml b/app/src/main/res/drawable/simkl_logo.xml new file mode 100644 index 00000000..eb29fb5b --- /dev/null +++ b/app/src/main/res/drawable/simkl_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/subdl_logo_big.xml b/app/src/main/res/drawable/subdl_logo_big.xml new file mode 100644 index 00000000..a6cbb311 --- /dev/null +++ b/app/src/main/res/drawable/subdl_logo_big.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/video_frame.xml b/app/src/main/res/drawable/video_frame.xml new file mode 100644 index 00000000..19fcf26d --- /dev/null +++ b/app/src/main/res/drawable/video_frame.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml new file mode 100644 index 00000000..9d39425a --- /dev/null +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item.xml b/app/src/main/res/layout/account_list_item.xml new file mode 100644 index 00000000..f133d6c3 --- /dev/null +++ b/app/src/main/res/layout/account_list_item.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item_add.xml b/app/src/main/res/layout/account_list_item_add.xml new file mode 100644 index 00000000..dea64484 --- /dev/null +++ b/app/src/main/res/layout/account_list_item_add.xml @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item_edit.xml b/app/src/main/res/layout/account_list_item_edit.xml new file mode 100644 index 00000000..0adade19 --- /dev/null +++ b/app/src/main/res/layout/account_list_item_edit.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_managment.xml b/app/src/main/res/layout/account_managment.xml index 389a3406..e7afb382 100644 --- a/app/src/main/res/layout/account_managment.xml +++ b/app/src/main/res/layout/account_managment.xml @@ -62,14 +62,16 @@ + android:id="@+id/account_switch_account" + android:text="@string/switch_account" + style="@style/SettingsItem" + android:focusable="true"/> + android:id="@+id/account_logout" + android:text="@string/logout" + style="@style/SettingsItem" + android:focusable="true"> diff --git a/app/src/main/res/layout/account_select_linear.xml b/app/src/main/res/layout/account_select_linear.xml new file mode 100644 index 00000000..b78c0d44 --- /dev/null +++ b/app/src/main/res/layout/account_select_linear.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_single.xml b/app/src/main/res/layout/account_single.xml index cbfb9f18..c4f7fa39 100644 --- a/app/src/main/res/layout/account_single.xml +++ b/app/src/main/res/layout/account_single.xml @@ -1,10 +1,11 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:foreground="?android:attr/selectableItemBackgroundBorderless" + android:orientation="horizontal" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:focusable="true"> + android:id="@+id/account_profile_picture" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:ignore="ContentDescription" /> + android:foreground="@null" + android:id="@+id/account_name" + tools:text="Account 1" + style="@style/SettingsItem" /> diff --git a/app/src/main/res/layout/account_switch.xml b/app/src/main/res/layout/account_switch.xml index 659ad840..5153f0e3 100644 --- a/app/src/main/res/layout/account_switch.xml +++ b/app/src/main/res/layout/account_switch.xml @@ -7,18 +7,20 @@ android:layout_height="match_parent"> + android:id="@+id/account_list" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + android:background="?attr/primaryBlackBackground" + tools:listitem="@layout/account_single" + android:layout_width="match_parent" + android:layout_rowWeight="1" + android:layout_height="wrap_content" + android:focusable="true"/> + android:id="@+id/account_add" + android:text="@string/add_account" + style="@style/SettingsItem" + android:focusable="true"> diff --git a/app/src/main/res/layout/activity_account_select.xml b/app/src/main/res/layout/activity_account_select.xml new file mode 100644 index 00000000..bd6007dc --- /dev/null +++ b/app/src/main/res/layout/activity_account_select.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main_tv.xml b/app/src/main/res/layout/activity_main_tv.xml index dc29dec9..a70a40cd 100644 --- a/app/src/main/res/layout/activity_main_tv.xml +++ b/app/src/main/res/layout/activity_main_tv.xml @@ -41,6 +41,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/add_remove_sites.xml b/app/src/main/res/layout/add_remove_sites.xml index 9ef6ad6a..653f607f 100644 --- a/app/src/main/res/layout/add_remove_sites.xml +++ b/app/src/main/res/layout/add_remove_sites.xml @@ -1,19 +1,21 @@ + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/add_site" + android:text="@string/add_site_pref" + android:focusable="true" + style="@style/SettingsItem"> + android:id="@+id/remove_site" + android:text="@string/remove_site_pref" + android:focusable="true" + style="@style/SettingsItem" /> \ No newline at end of file diff --git a/app/src/main/res/layout/add_repo_input.xml b/app/src/main/res/layout/add_repo_input.xml index 6f6b4d5b..cb4224d1 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -72,7 +72,7 @@ android:inputType="text" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - android:nextFocusDown="@id/site_url_input" + android:nextFocusDown="@id/repo_url_input" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> @@ -85,9 +85,8 @@ android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - - android:nextFocusUp="@id/site_name_input" - android:nextFocusDown="@id/site_lang_input" + android:nextFocusUp="@id/repo_name_input" + android:nextFocusDown="@id/apply_btt" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> diff --git a/app/src/main/res/layout/bottom_resultview_preview.xml b/app/src/main/res/layout/bottom_resultview_preview.xml index ce41cb65..3372fe7b 100644 --- a/app/src/main/res/layout/bottom_resultview_preview.xml +++ b/app/src/main/res/layout/bottom_resultview_preview.xml @@ -41,17 +41,50 @@ android:layout_marginStart="10dp" android:orientation="vertical"> - - android:textStyle="bold" - tools:text="The Perfect Run"> + - + + + + + - + android:orientation="horizontal" + android:padding="7dp"> + + + + + + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + android:id="@+id/text1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_rowWeight="1" + android:layout_marginTop="20dp" + android:layout_marginBottom="10dp" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:textColor="?attr/textColor" + android:textSize="20sp" + android:textStyle="bold" + tools:text="Test" /> + android:id="@+id/listview1" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:layout_marginBottom="60dp" + android:nestedScrollingEnabled="true" + android:nextFocusLeft="@id/apply_btt" + android:nextFocusRight="@id/cancel_btt" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + tools:listitem="@layout/sort_bottom_single_choice_no_checkmark" /> diff --git a/app/src/main/res/layout/bottom_text_dialog.xml b/app/src/main/res/layout/bottom_text_dialog.xml new file mode 100644 index 00000000..01b4834d --- /dev/null +++ b/app/src/main/res/layout/bottom_text_dialog.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 8403940c..99a9750b 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -1,105 +1,98 @@ - android:foreground="@drawable/outline_drawable" - android:layout_margin="5dp"> + android:layout_width="100dp" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="5dp"> + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal"> + android:layout_width="70dp" + android:layout_height="70dp" + android:foreground="@drawable/outline_drawable" + app:cardCornerRadius="35dp"> + android:id="@+id/actor_image" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" + android:scaleType="centerCrop" + tools:src="@drawable/example_poster" /> + android:id="@+id/voice_actor_image_holder" + android:layout_width="40dp" + android:layout_height="40dp" + android:layout_gravity="end|bottom" + android:foreground="@drawable/outline_drawable" + app:cardCornerRadius="20dp"> + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" + android:scaleType="centerCrop" + tools:src="@drawable/example_poster" /> + + + android:textColor="?attr/textColor" + android:textStyle="bold" + tools:text="Ackerman, Mikasa" /> + android:id="@+id/voice_actor_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:textColor="?attr/grayTextColor" + tools:text="voiceactor" /> - - + android:id="@+id/actor_extra" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:textColor="?attr/grayTextColor" + tools:text="Main" /> diff --git a/app/src/main/res/layout/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 624c2201..4d3b50df 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -35,7 +35,7 @@ android:layout_height="match_parent" android:contentDescription="@string/preview_background_img_des" /> - + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_online_subtitles.xml b/app/src/main/res/layout/dialog_online_subtitles.xml index 7803e261..e0eac5e0 100644 --- a/app/src/main/res/layout/dialog_online_subtitles.xml +++ b/app/src/main/res/layout/dialog_online_subtitles.xml @@ -40,7 +40,7 @@ android:layout_width="match_parent" android:layout_height="30dp" android:layout_gravity="center_vertical" - android:layout_marginEnd="30dp"> + android:layout_marginEnd="40dp"> @@ -106,7 +107,8 @@ android:layout_margin="10dp" android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/change_providers_img_des" - android:nextFocusLeft="@id/main_search" + android:focusable="true" + android:nextFocusLeft="@id/year_btt" android:nextFocusRight="@id/main_search" android:nextFocusUp="@id/nav_rail_view" android:nextFocusDown="@id/search_autofit_results" diff --git a/app/src/main/res/layout/download_button.xml b/app/src/main/res/layout/download_button.xml new file mode 100644 index 00000000..e8023243 --- /dev/null +++ b/app/src/main/res/layout/download_button.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_button_layout.xml b/app/src/main/res/layout/download_button_layout.xml new file mode 100644 index 00000000..0ceca181 --- /dev/null +++ b/app/src/main/res/layout/download_button_layout.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_button_view.xml b/app/src/main/res/layout/download_button_view.xml new file mode 100644 index 00000000..6e40a597 --- /dev/null +++ b/app/src/main/res/layout/download_button_view.xml @@ -0,0 +1,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index f2633dd6..e53e63d3 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -1,118 +1,98 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/download_child_episode_holder" + android:layout_width="match_parent" + android:layout_height="50dp" + android:layout_marginBottom="5dp" + android:foreground="@drawable/outline_drawable" + android:focusable="true" + android:nextFocusLeft="@id/nav_rail_view" + android:nextFocusRight="@id/download_button" + app:cardBackgroundColor="@color/transparent" + app:cardCornerRadius="@dimen/rounded_image_radius" + app:cardElevation="0dp"> + android:id="@+id/download_child_episode_progress" + style="@android:style/Widget.Material.ProgressBar.Horizontal" + android:layout_width="match_parent" + android:layout_height="5dp" + android:layout_gravity="bottom" + android:layout_marginBottom="-1.5dp" + android:progressBackgroundTint="?attr/colorPrimary" + android:progressTint="?attr/colorPrimary" + tools:progress="50" /> + android:layout_width="match_parent" + android:layout_height="match_parent" + android:foreground="?android:attr/selectableItemBackgroundBorderless"> + android:id="@+id/download_child_episode_play" + android:layout_gravity="center_vertical" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:contentDescription="@string/episode_play_img_des" + android:src="@drawable/ic_baseline_play_arrow_24" + android:visibility="gone" /> + + + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:ellipsize="marquee" + android:gravity="center_vertical" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:singleLine="true" + android:textColor="?attr/textColor" + tools:text="Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1" /> - - + android:id="@+id/download_child_episode_text_extra" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:layout_gravity="center_vertical" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:gravity="center_vertical" + android:textColor="?attr/grayTextColor" + tools:text="128MB / 237MB" /> - + - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/download_header_episode.xml b/app/src/main/res/layout/download_header_episode.xml index da4b3617..385fb2e0 100644 --- a/app/src/main/res/layout/download_header_episode.xml +++ b/app/src/main/res/layout/download_header_episode.xml @@ -1,103 +1,103 @@ + + + android:layout_height="5dp" + android:layout_gravity="bottom" + android:layout_marginBottom="-1.5dp" + android:progressBackgroundTint="?attr/colorPrimary" + android:progressTint="?attr/colorPrimary" + tools:progress="50" /> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:foreground="?android:attr/selectableItemBackgroundBorderless" + android:orientation="horizontal"> + android:layout_width="70dp" + android:layout_height="104dp"> + android:id="@+id/download_header_poster" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" + android:scaleType="centerCrop" + tools:src="@drawable/example_poster" /> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginStart="15dp" + android:layout_marginEnd="70dp" + android:orientation="vertical"> + android:id="@+id/download_header_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="?attr/textColor" + android:textStyle="bold" + tools:text="Perfect Run" /> + android:id="@+id/download_header_info" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="?attr/grayTextColor" + tools:text="1 episode | 285MB" /> + android:id="@+id/download_header_goto_child" + android:layout_width="@dimen/download_size" + android:layout_height="@dimen/download_size" + android:layout_gravity="center_vertical|end" + android:layout_marginStart="-50dp" + android:contentDescription="@string/download" + android:padding="10dp" + android:src="@drawable/ic_baseline_keyboard_arrow_right_24" /> - + - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index a3cc8ce8..64ed1d70 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -1,39 +1,95 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/download_child_root" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/primaryGrayBackground" + android:orientation="vertical" + tools:context=".ui.download.DownloadChildFragment"> + + + android:layout_height="wrap_content" + android:orientation="horizontal" + android:background="?attr/colorPrimary" + android:padding="8dp" + android:visibility="gone"> + + + +