diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml index f3590067..931db3bd 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 b56cdf8e..250734cd 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: 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. + 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. - 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 e18daebb..9c35ba56 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -27,7 +27,9 @@ 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 \ No newline at end of file + 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 diff --git a/.github/downloads.jpg b/.github/downloads.jpg new file mode 100644 index 00000000..ca14a664 Binary files /dev/null and b/.github/downloads.jpg differ diff --git a/.github/home.jpg b/.github/home.jpg new file mode 100644 index 00000000..72370d3c Binary files /dev/null and b/.github/home.jpg differ diff --git a/.github/locales.py b/.github/locales.py index a74d7258..1c79c093 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,8 +1,6 @@ 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" @@ -47,23 +45,4 @@ open(SETTINGS_PATH, "w+",encoding='utf-8').write( "\n" + END_MARKER + after_src -) - -# 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}") +) \ No newline at end of file diff --git a/.github/player.jpg b/.github/player.jpg new file mode 100644 index 00000000..f6959cf3 Binary files /dev/null and b/.github/player.jpg differ diff --git a/.github/results.jpg b/.github/results.jpg new file mode 100644 index 00000000..4dbc9b8d Binary files /dev/null and b/.github/results.jpg differ diff --git a/.github/search.jpg b/.github/search.jpg new file mode 100644 index 00000000..784bec89 Binary files /dev/null and b/.github/search.jpg differ diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index e84bb08b..83430766 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@v2 + uses: tibdex/github-app-token@v1 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@v2 + uses: tibdex/github-app-token@v1 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream-archive" - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v2 + - name: Set up JDK 11 + uses: actions/setup-java@v2 with: - java-version: '17' + java-version: '11' distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -56,9 +56,7 @@ jobs: 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 }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 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 96e61644..3c5caad7 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@v2 + uses: tibdex/github-app-token@v1 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} @@ -42,14 +42,13 @@ jobs: cd $GITHUB_WORKSPACE/dokka/ rm -rf "./-cloudstream" - - name: Setup JDK 17 - uses: actions/setup-java@v4 + - name: Setup JDK 11 + uses: actions/setup-java@v1 with: - java-version: 17 - distribution: 'adopt' + java-version: 11 - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v2 - name: Generate Dokka run: | diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml index 88ab3656..108cec82 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@v2 + uses: tibdex/github-app-token@v1 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@v7 + uses: actions/github-script@v6 with: github-token: ${{ steps.generate_token.outputs.token }} script: | @@ -37,7 +37,7 @@ jobs: repo: context.repo.repo, labels: ["possible duplicate"] }) - - uses: actions/checkout@v4 + - uses: actions/checkout@v2 - 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@v7 + uses: actions/github-script@v6 with: github-token: ${{ steps.generate_token.outputs.token }} script: | diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index f35cd58c..4ce7dba1 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@v2 + uses: tibdex/github-app-token@v1 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v2 + - name: Set up JDK 11 + uses: actions/setup-java@v2 with: - java-version: '17' + java-version: '11' distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -43,14 +43,11 @@ jobs: echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - name: Run Gradle run: | - ./gradlew assemblePrerelease build androidSourcesJar - ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease + ./gradlew assemblePrerelease makeJar androidSourcesJar 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 7f6dd412..36199cd6 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@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v2 + - name: Set up JDK 11 + uses: actions/setup-java@v2 with: - java-version: '17' + java-version: '11' 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@v4 + uses: actions/upload-artifact@v2 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 ce140e55..93cdca44 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,4 +1,4 @@ -name: Fix locale issues +name: Update locale lists on: workflow_dispatch: @@ -9,7 +9,7 @@ on: - master concurrency: - group: "locale" + group: "locale-list" cancel-in-progress: true jobs: @@ -18,17 +18,14 @@ jobs: steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v2 + uses: tibdex/github-app-token@v1 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - uses: actions/checkout@v4 + - uses: actions/checkout@v2 with: token: ${{ steps.generate_token.outputs.token }} - - name: Install dependencies - run: | - pip3 install lxml - name: Edit files run: | python3 .github/locales.py @@ -38,5 +35,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 'chore(locales): fix locale issues' || echo + git commit -m 'update list of locales' || echo git push diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b589d56e..5421743a 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 d7c08c9c..10c26704 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,16 +4,17 @@ diff --git a/README.md b/README.md index 8949304e..3430d626 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,15 @@ + **AdFree**, No ads whatsoever + No tracking/analytics + Bookmarks -+ Phone and TV support ++ Download and stream movies, tv-shows and anime + Chromecast -+ Extension system for personal customization + +### Screenshots: + + + ### Supported languages: Translation status - + \ No newline at end of file diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt deleted file mode 100644 index 7f7fd14c..00000000 --- a/app/CMakeLists.txt +++ /dev/null @@ -1,6 +0,0 @@ -# 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 d0c86bab..3c855d28 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,14 +1,13 @@ -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import com.android.build.gradle.api.BaseVariantOutput 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") } @@ -20,7 +19,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 } @@ -29,21 +28,9 @@ android { testOptions { unitTests.isReturnDefaultValues = true } - - viewBinding { - enable = true - } - - /* disable this for now - externalNativeBuild { - cmake { - path("CMakeLists.txt") - } - }*/ - signingConfigs { - if (prereleaseStoreFile != null) { - create("prerelease") { + create("prerelease") { + if (prereleaseStoreFile != null) { storeFile = file(prereleaseStoreFile) storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") @@ -52,44 +39,33 @@ android { } } - compileSdk = 34 - buildToolsVersion = "34.0.0" + compileSdk = 33 + buildToolsVersion = "30.0.3" defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = 21 - 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" + targetSdk = 33 + + versionCode = 57 + versionName = "4.0.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( + "String", + "BUILDDATE", + "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" + ) - buildConfigField( - "long", - "BUILD_DATE", - "${System.currentTimeMillis()}" - ) - buildConfigField( - "String", - "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" - ksp { - arg("room.schemaLocation", "$projectDir/schemas") - arg("exportSchema", "true") + kapt { + includeCompileClasspath = true } } @@ -98,21 +74,14 @@ 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") { @@ -124,31 +93,25 @@ android { resValue("bool", "is_prerelease", "true") buildConfigField("boolean", "BETA", "true") applicationIdSuffix = ".prerelease" - if (signingConfigs.names.contains("prerelease")) { - signingConfig = signingConfigs.getByName("prerelease") - } else { - logger.warn("No prerelease signing config!") - } + signingConfig = signingConfigs.getByName("prerelease") 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" } @@ -157,132 +120,122 @@ repositories { } dependencies { - // Testing - testImplementation("junit:junit:4.13.2") - 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("com.google.android.mediahome:video:1.0.0") + implementation("androidx.test.ext:junit-ktx:1.1.3") + testImplementation("org.json:json:20180813") - // 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") + implementation("androidx.core:core-ktx:1.8.0") + implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0 - // 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") + // 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") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.3") + androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + + //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") + + implementation("jp.wasabeef:glide-transformations:4.3.0") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - // 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") + // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09") - // 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") + // 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") - // 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") + //implementation("com.google.android.exoplayer:extension-leanback:2.14.0") - // 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 + // Bug reports + implementation("ch.acra:acra-core:5.8.4") + implementation("ch.acra:acra-toast:5.8.4") - // Crash Reports (AcraApplication.kt) - implementation("ch.acra:acra-core:5.11.3") - implementation("ch.acra:acra-toast:5.11.3") + 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") - // 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 - // 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. */ + // used for subtitle decoding https://github.com/albfernandez/juniversalchardet + implementation("com.github.albfernandez:juniversalchardet:2.4.0") - // 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 + // slow af yt + //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT") - 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) - } - } + // 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") - this.extra.set("isDebug", isDebug) - }) + // 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") } -tasks.register("androidSourcesJar") { +tasks.register("androidSourcesJar", Jar::class) { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources + from(android.sourceSets.getByName("main").java.srcDirs) //full sources } -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") - } +// 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.withType().configureEach { @@ -295,7 +248,6 @@ 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 c7f02baf..81753f6b 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -1,57 +1,155 @@ 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 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.mvvm.logError +import com.lagradost.cloudstream3.utils.Qualities 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 { - private fun getAllProviders(): Array { - println("Providers: ${APIHolder.allProviders.size}") - return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView } + //@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 + } } @Test @@ -60,78 +158,7 @@ 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()) @@ -153,20 +180,68 @@ class ExampleInstrumentedTest { @Test fun providerCorrectHomepage() { runBlocking { - getAllProviders().toList().amap { api -> - TestingUtils.testHomepage(api, TestingUtils.Logger()) + 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) + } + } } } println("Done providerCorrectHomepage") } +// @Test +// fun testSingleProvider() { +// testSingleProviderApi(ThenosProvider()) +// } + @Test - fun testAllProvidersCorrect() { + fun providerCorrect() { runBlocking { - TestingUtils.getDeferredProviderTests( - this, - getAllProviders(), - ) { _, _ -> } + 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}") + } + } } + println("Done providerCorrect") } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 888be999..871c4f69 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ - + @@ -14,14 +14,8 @@ - - - - - + + tools:targetApi="o"> + android:supportsPictureInPicture="true"> @@ -97,20 +87,16 @@ --> + + - - - - - - - - + + @@ -165,21 +151,6 @@ - - - - - - - - - - - - @@ -187,14 +158,13 @@ - + android:exported="true"> + @@ -204,7 +174,6 @@ android:exported="false" /> diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp deleted file mode 100644 index f4cb531f..00000000 --- a/app/src/main/cpp/native-lib.cpp +++ /dev/null @@ -1,28 +0,0 @@ -#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 d6f978fe..0351b1ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -8,14 +8,12 @@ import android.content.Intent import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import com.lagradost.api.setContext +import com.google.auto.service.AutoService import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager -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.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.utils.AppUtils.openBrowser import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys @@ -34,26 +32,27 @@ 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/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse" + "https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse" val data = mapOf( - "entry.1993829403" to errorContent.toJSON() + "entry.753293084" to errorContent.toJSON() ) thread { // to not run it on main thread runBlocking { suspendSafeApiCall { - app.post(url, data = data) - //println("Report response: $post") + val post = app.post(url, data = data) + println("Report response: $post") } } } @@ -66,6 +65,7 @@ class CustomReportSender : ReportSender { } } +@AutoService(ReportSenderFactory::class) class CustomSenderFactory : ReportSenderFactory { override fun create(context: Context, config: CoreConfiguration): ReportSender { return CustomReportSender() @@ -82,8 +82,14 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : ACRA.errorReporter.handleException(error) try { PrintStream(errorFile).use { ps -> - ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") - ps.println("Fatal exception on thread ${thread.name} (${thread.id})") + ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")) + ps.println( + String.format( + "Fatal exception on thread %s (%d)", + thread.name, + thread.id + ) + ) error.printStackTrace(ps) } } catch (ignored: FileNotFoundException) { @@ -98,16 +104,12 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : } class AcraApplication : Application() { - override fun onCreate() { super.onCreate() - ExceptionHandler(filesDir.resolve("last_error")) { + Thread.setDefaultUncaughtExceptionHandler(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?) { @@ -119,10 +121,10 @@ class AcraApplication : Application() { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON - reportContent = listOf( + reportContent = arrayOf( 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 @@ -135,8 +137,6 @@ 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,17 +146,8 @@ 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) } @@ -208,9 +199,10 @@ class AcraApplication : Application() { fun openBrowser(url: String, activity: FragmentActivity?) { openBrowser( url, - isLayout(TV or EMULATOR), + isTvSettings(), 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 ee3a5d12..89f0ae51 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -5,16 +5,11 @@ 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.Gravity -import android.view.KeyEvent -import android.view.View -import android.view.View.NO_ID -import android.view.ViewGroup +import android.view.* +import android.widget.TextView import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts @@ -23,21 +18,15 @@ 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.Globals.updateTv -import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.UIHelper @@ -45,50 +34,14 @@ 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.lang.ref.WeakReference -import java.util.Locale -import kotlin.math.max -import kotlin.math.min - -enum class FocusDirection { - Start, - End, - Up, - Down, -} +import java.util.* 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 @@ -100,32 +53,9 @@ object CommonActivity { var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null - private var currentToast: Toast? = null - fun showToast(@StringRes message: Int, duration: Int? = null) { - val act = activity ?: return - act.runOnUiThread { - showToast(act, act.getString(message), duration) - } - } + var currentToast: Toast? = null - 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 { @@ -156,19 +86,25 @@ object CommonActivity { } catch (e: Exception) { logError(e) } - try { - val binding = ToastBinding.inflate(act.layoutInflater) - binding.text.text = message.trim() + 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() - // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11) val toast = Toast(act) - toast.duration = duration ?: Toast.LENGTH_SHORT 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.duration = duration ?: Toast.LENGTH_SHORT + toast.view = layout + //https://github.com/PureWriter/ToastCompat toast.show() - + currentToast = toast } catch (e: Exception) { logError(e) } @@ -202,25 +138,22 @@ object CommonActivity { setLocale(this, localeCode) } - fun init(act: Activity) { - setActivityInstance(act) - - val componentActivity = activity as? ComponentActivity ?: return - + fun init(act: ComponentActivity?) { + if (act == null) 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 - 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.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.updateLocale() - componentActivity.updateTv() + act.updateLocale() + act.updateTv() NewPipe.init(DownloaderTestImpl.getInstance()) for (resumeApp in resumeApps) { resumeApp.launcher = - componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + act.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) { @@ -237,11 +170,11 @@ object CommonActivity { // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( - componentActivity, + act, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { - val requestPermissionLauncher = componentActivity.registerForActivityResult( + val requestPermissionLauncher = act.registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> Log.d(TAG, "Notification permission: $isGranted") @@ -277,57 +210,30 @@ 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 @@ -336,13 +242,10 @@ 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) @@ -354,179 +257,101 @@ object CommonActivity { ) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW } - /** 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?, + private fun getNextFocus( + act: Activity?, view: View?, direction: FocusDirection, depth: Int = 0 - ): View? { - // if input is invalid let android decide + depth test to not crash if loop is found - if (view == null || depth >= 10 || root == null) { + ): Int? { + if (view == null || depth >= 10 || act == null) { return null } - var nextId = when (direction) { - FocusDirection.Start -> { - if (view.isRtl()) - view.nextFocusRightId - else - view.nextFocusLeftId + val nextId = when (direction) { + FocusDirection.Left -> { + view.nextFocusLeftId } - FocusDirection.Up -> { view.nextFocusUpId } - - FocusDirection.End -> { - if (view.isRtl()) - view.nextFocusLeftId - else - view.nextFocusRightId + FocusDirection.Right -> { + view.nextFocusRightId } - FocusDirection.Down -> { view.nextFocusDownId } } - 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 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 } - 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 } @@ -534,27 +359,21 @@ 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) @@ -567,64 +386,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 -> - if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let - val nextView = when (keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( - act, - currentFocus, - FocusDirection.Start - ) + 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 + ) - KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus( - act, - currentFocus, - FocusDirection.End - ) + else -> null + } - KeyEvent.KEYCODE_DPAD_UP -> getNextFocus( - act, - currentFocus, - FocusDirection.Up - ) + 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_DOWN -> getNextFocus( - act, - currentFocus, - FocusDirection.Down - ) - - else -> null + 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 + //) + } } - // 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 8da7ca38..379a91e4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt @@ -2,7 +2,6 @@ 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 @@ -11,7 +10,7 @@ import java.util.concurrent.TimeUnit class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { - private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build() + private val client: OkHttpClient override fun execute(request: Request): Response { val httpMethod: String = request.httpMethod() val url: String = request.url() @@ -19,7 +18,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do val dataToSend: ByteArray? = request.dataToSend() var requestBody: RequestBody? = null if (dataToSend != null) { - requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size) + requestBody = RequestBody.create(null, dataToSend) } val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .method(httpMethod, requestBody).url(url) @@ -51,7 +50,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do companion object { private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" private var instance: DownloaderTestImpl? = null /** @@ -74,4 +73,8 @@ 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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt similarity index 69% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt rename to app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt index 50dd667b..4014e34d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt @@ -1,43 +1,41 @@ package com.lagradost.cloudstream3 +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.util.Base64.encodeToString +import androidx.annotation.WorkerThread +import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty 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.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf -import com.lagradost.nicehttp.RequestBodyTypes import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.RequestBody.Companion.toRequestBody -import java.net.URI import java.text.SimpleDateFormat import java.util.* -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue +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" + +//val baseHeader = mapOf("User-Agent" to USER_AGENT) +val mapper = JsonMapper.builder().addModule(KotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! + /** * Defines the constant for the all languages preference, if this is set then it is * the equivalent of all languages being set **/ const val AllLanguagesName = "universal" -const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" - -class ErrorLoadingException(message: String? = null) : Exception(message) - -//val baseHeader = mapOf("User-Agent" to USER_AGENT) -val mapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! - object APIHolder { val unixTime: Long get() = System.currentTimeMillis() / 1000L @@ -50,10 +48,8 @@ object APIHolder { val allProviders = threadSafeListOf() fun initAll() { - synchronized(allProviders) { - for (api in allProviders) { - api.init() - } + for (api in allProviders) { + api.init() } apiMap = null } @@ -66,35 +62,27 @@ object APIHolder { var apiMap: Map? = null fun addPluginMapping(plugin: MainAPI) { - synchronized(apis) { - apis = apis + plugin - } + apis = apis + plugin initMap(true) } fun removePluginMapping(plugin: MainAPI) { - synchronized(apis) { - apis = apis.filter { it != plugin } - } + apis = apis.filter { it != plugin } initMap(true) } private fun initMap(forcedUpdate: Boolean = false) { - synchronized(apis) { - if (apiMap == null || forcedUpdate) - apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap() - } + if (apiMap == null || forcedUpdate) + apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap() } fun getApiFromNameNull(apiName: String?): MainAPI? { if (apiName == null) return null synchronized(allProviders) { initMap() - synchronized(apis) { - return apiMap?.get(apiName)?.let { apis.getOrNull(it) } - // Leave the ?. null check, it can crash regardless - ?: allProviders.firstOrNull { it.name == apiName } - } + return apiMap?.get(apiName)?.let { apis.getOrNull(it) } + // Leave the ?. null check, it can crash regardless + ?: allProviders.firstOrNull { it?.name == apiName } } } @@ -108,6 +96,15 @@ object APIHolder { return null } + private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int { + return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "") + .hashCode() + } + + fun LoadResponse.getId(): Int { + return getLoadResponseIdFromUrl(url, apiName) + } + /** * Gets the website captcha token * discovered originally by https://github.com/ahmedgamal17 @@ -123,9 +120,10 @@ object APIHolder { // To get the key suspend fun getCaptchaToken(url: String, key: String, referer: String? = null): String? { try { - val uri = URI.create(url) - val domain = base64Encode( + val uri = Uri.parse(url) + val domain = encodeToString( (uri.scheme + "://" + uri.host + ":443").encodeToByteArray(), + 0 ).replace("\n", "").replace("=", ".") val vToken = @@ -164,17 +162,10 @@ object APIHolder { private var trackerCache: HashMap = hashMapOf() - /** backwards compatibility, use getTracker4 instead */ - suspend fun getTracker( - titles: List, - types: Set?, - year: Int?, - ): Tracker? = getTracker(titles, types, year, false) - /** * Get anime tracker information based on title, year and type. * Both titles are attempted to be matched with both Romaji and English title. - * Uses the anilist api. + * Uses the consumet api. * * @param titles uses first index to search, but if you have multiple titles and want extra guarantee to match you can also have that * @param types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes() @@ -183,8 +174,7 @@ object APIHolder { suspend fun getTracker( titles: List, types: Set?, - year: Int?, - lessAccurate: Boolean + year: Int? ): Tracker? { return try { require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" } @@ -192,73 +182,186 @@ object APIHolder { val mainTitle = titles[0] val search = trackerCache[mainTitle] - ?: searchAnilist(mainTitle)?.also { - trackerCache[mainTitle] = it - } ?: return null + ?: app.get("https://api.consumet.org/meta/anilist/$mainTitle") + .parsedSafe()?.also { + trackerCache[mainTitle] = it + } ?: return null - val res = search.data?.page?.media?.find { media -> - val matchingYears = year == null || media.seasonYear == year + val res = search.results?.find { media -> + val matchingYears = year == null || media.releaseDate == year val matchingTitles = media.title?.let { title -> titles.any { userTitle -> title.isMatchingTitles(userTitle) } } ?: false - val matchingTypes = types?.any { it.name.equals(media.format, true) } == true - if (lessAccurate) matchingTitles || matchingTypes && matchingYears else matchingTitles && matchingTypes && matchingYears + val matchingTypes = types?.any { it.name.equals(media.type, true) } == true + matchingTitles && matchingTypes && matchingYears } ?: return null - Tracker( - res.idMal, - res.id.toString(), - res.coverImage?.extraLarge ?: res.coverImage?.large, - res.bannerImage - ) + Tracker(res.malId, res.aniId, res.image, res.cover) } catch (t: Throwable) { logError(t) null } } - private suspend fun searchAnilist( - title: String?, - ): AniSearch? { - val query = """ - query ( - ${'$'}page: Int = 1 - ${'$'}search: String - ${'$'}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC] - ${'$'}type: MediaType - ) { - Page(page: ${'$'}page, perPage: 20) { - media( - search: ${'$'}search - sort: ${'$'}sort - type: ${'$'}type - ) { - id - idMal - title { romaji english } - coverImage { extraLarge large } - bannerImage - seasonYear - format + + fun Context.getApiSettings(): HashSet { + //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + + val hashSet = HashSet() + val activeLangs = getApiProviderLangSettings() + val hasUniversal = activeLangs.contains(AllLanguagesName) + hashSet.addAll(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 = apis.filter { hasUniversal || langs.contains(it.lang) } + .filter { api -> 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) } } } - """.trimIndent().trim() + } - val data = mapOf( - "query" to query, - "variables" to mapOf( - "search" to title, - "sort" to "SEARCH_MATCH", - "type" to "ANIME", - ) - ).toJson().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull()) + 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 + } - return app.post("https://graphql.anilist.co", requestBody = data) - .parsedSafe() + 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 } } @@ -448,7 +551,7 @@ abstract class MainAPI { /**Used for testing and can be used to disable the providers if WebView is not available*/ open val usesWebView = false - /** Determines which plugin a given provider is from. This is the full path to the plugin. */ + /** Determines which plugin a given provider is from */ var sourcePlugin: String? = null open val hasMainPage = false @@ -482,7 +585,7 @@ abstract class MainAPI { //emptyList() // open val mainPage = listOf(MainPageData("", "", false)) - // @WorkerThread + @WorkerThread open suspend fun getMainPage( page: Int, request: MainPageRequest, @@ -490,17 +593,17 @@ abstract class MainAPI { throw NotImplementedError() } - // @WorkerThread + @WorkerThread open suspend fun search(query: String): List? { throw NotImplementedError() } - // @WorkerThread + @WorkerThread open suspend fun quickSearch(query: String): List? { throw NotImplementedError() } - // @WorkerThread + @WorkerThread /** * Based on data from search() or getMainPage() it generates a LoadResponse, * basically opening the info page from a link. @@ -518,13 +621,13 @@ abstract class MainAPI { * This function might be updated to include exoplayer timestamps etc in the future * if the need arises. * */ - // @WorkerThread + @WorkerThread open suspend fun extractorVerifierJob(extractorData: String?) { throw NotImplementedError() } /**Callback is fired once a link is found, will return true if method is executed successfully*/ - // @WorkerThread + @WorkerThread open suspend fun loadLinks( data: String, isCasting: Boolean, @@ -549,18 +652,31 @@ abstract class MainAPI { } /** Might need a different implementation for desktop*/ +@SuppressLint("NewApi") fun base64Decode(string: String): String { return String(base64DecodeArray(string), Charsets.ISO_8859_1) } -@OptIn(ExperimentalEncodingApi::class) + +@SuppressLint("NewApi") fun base64DecodeArray(string: String): ByteArray { - return Base64.decode(string) + return try { + android.util.Base64.decode(string, android.util.Base64.DEFAULT) + } catch (e: Exception) { + Base64.getDecoder().decode(string) + } } -@OptIn(ExperimentalEncodingApi::class) + +@SuppressLint("NewApi") fun base64Encode(array: ByteArray): String { - return Base64.encode(array) + return try { + String(android.util.Base64.encode(array, android.util.Base64.NO_WRAP), Charsets.ISO_8859_1) + } catch (e: Exception) { + String(Base64.getEncoder().encode(array)) + } } +class ErrorLoadingException(message: String? = null) : Exception(message) + fun MainAPI.fixUrlNull(url: String?): String? { if (url.isNullOrEmpty()) { return null @@ -594,6 +710,10 @@ fun sortUrls(urls: Set): List { return urls.sortedBy { t -> -t.quality } } +fun sortSubs(subs: Set): List { + return subs.sortedBy { it.name } +} + fun capitalizeString(str: String): String { return capitalizeStringNullable(str) ?: str } @@ -615,20 +735,6 @@ fun fixTitle(str: String): String { } } -/** - * Get rhino context in a safe way as it needs to be initialized on the main thread. - * Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects() - * Use like the following: rhino.evaluateString(scope, js, "JavaScript", 1, null) - **/ -suspend fun getRhinoContext(): org.mozilla.javascript.Context { - return Coroutines.mainWork { - val rhino = org.mozilla.javascript.Context.enter() - rhino.initSafeStandardObjects() - rhino.optimizationLevel = -1 - rhino - } -} - /** https://www.imdb.com/title/tt2861424/ -> tt2861424 */ fun imdbUrlToId(url: String): String? { return Regex("/title/(tt[0-9]*)").find(url)?.groupValues?.get(1) @@ -677,25 +783,7 @@ enum class TvType(value: Int?) { AsianDrama(9), Live(10), NSFW(11), - Others(12), - Music(13), - AudioBook(14), - - /** Wont load the built in player, make your own interaction */ - CustomMedia(15), -} - -public enum class AutoDownloadMode(val value: Int) { - Disable(0), - FilterByLang(1), - All(2), - NsfwOnly(3) - ; - - companion object { - infix fun getEnum(value: Int): AutoDownloadMode? = - AutoDownloadMode.values().firstOrNull { it.value == value } - } + Others(12) } // IN CASE OF FUTURE ANIME MOVIE OR SMTH @@ -1012,30 +1100,14 @@ interface LoadResponse { var syncData: MutableMap var posterHeaders: Map? var backgroundPosterUrl: String? - var contentRating: String? companion object { - var malIdPrefix = "" //malApi.idPrefix - var aniListIdPrefix = "" //aniListApi.idPrefix - var simklIdPrefix = "" //simklApi.idPrefix + private val malIdPrefix = malApi.idPrefix + private val aniListIdPrefix = aniListApi.idPrefix var isTrailersEnabled = true - /** - * The ID string is a way to keep a collection of services in one single ID using a map - * This adds a database service (like imdb) to the string and returns the new string. - */ - fun addIdToString(idString: String?, database: SimklSyncServices, id: String?): String? { - if (id == null) return idString - return (readIdFromString(idString) + mapOf(database to id)).toJson() - } - - /** Read the id string to get all other ids */ - fun readIdFromString(idString: String?): Map { - return tryParseJson(idString) ?: return emptyMap() - } - fun LoadResponse.isMovie(): Boolean { - return this.type.isMovieType() || this is MovieLoadResponse + return this.type.isMovieType() } @JvmName("addActorNames") @@ -1053,20 +1125,6 @@ interface LoadResponse { this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) } } - /** - * Internal helper function to add simkl ids from other databases. - */ - private fun LoadResponse.addSimklId( - database: SimklSyncServices, - id: String? - ) { - normalSafeApiCall { - this.syncData[simklIdPrefix] = - addIdToString(this.syncData[simklIdPrefix], database, id.toString()) - ?: return@normalSafeApiCall - } - } - @JvmName("addActorsOnly") fun LoadResponse.addActors(actors: List?) { this.actors = actors?.map { actor -> ActorData(actor) } @@ -1080,30 +1138,12 @@ interface LoadResponse { return this.syncData[aniListIdPrefix] } - fun LoadResponse.getImdbId(): String? { - return normalSafeApiCall { - readIdFromString(this.syncData[simklIdPrefix])[SimklSyncServices.Imdb] - } - } - - fun LoadResponse.getTMDbId(): String? { - return normalSafeApiCall { - readIdFromString(this.syncData[simklIdPrefix])[SimklSyncServices.Tmdb] - } - } - fun LoadResponse.addMalId(id: Int?) { this.syncData[malIdPrefix] = (id ?: return).toString() - this.addSimklId(SimklSyncServices.Mal, id.toString()) } fun LoadResponse.addAniListId(id: Int?) { this.syncData[aniListIdPrefix] = (id ?: return).toString() - this.addSimklId(SimklSyncServices.AniList, id.toString()) - } - - fun LoadResponse.addSimklId(id: Int?) { - this.addSimklId(SimklSyncServices.Simkl, id.toString()) } fun LoadResponse.addImdbUrl(url: String?) { @@ -1185,7 +1225,6 @@ interface LoadResponse { fun LoadResponse.addImdbId(id: String?) { // TODO add imdb sync - this.addSimklId(SimklSyncServices.Imdb, id) } fun LoadResponse.addTrackId(id: String?) { @@ -1198,7 +1237,6 @@ interface LoadResponse { fun LoadResponse.addTMDbId(id: String?) { // TODO add TMDb sync - this.addSimklId(SimklSyncServices.Tmdb, id) } fun LoadResponse.addRating(text: String?) { @@ -1274,27 +1312,14 @@ fun LoadResponse?.isAnimeBased(): Boolean { fun TvType?.isEpisodeBased(): Boolean { if (this == null) return false - return (this == TvType.TvSeries || this == TvType.Anime || this == TvType.AsianDrama) + return (this == TvType.TvSeries || this == TvType.Anime) } + data class NextAiring( val episode: Int, val unixTime: Long, - val season: Int? = null, -) { - /** - * Secondary constructor for backwards compatibility without season. - * TODO Remove this constructor after there is a new stable release and extensions are updated to support season. - */ - constructor( - episode: Int, - unixTime: Long, - ) : this( - episode, - unixTime, - null - ) -} +) /** * @param season To be mapped with episode season, not shown in UI if displaySeason is defined @@ -1311,16 +1336,6 @@ interface EpisodeResponse { var showStatus: ShowStatus? var nextAiring: NextAiring? var seasonNames: List? - fun getLatestEpisodes(): Map - - /** Count all episodes in all previous seasons up until this episode to get a total count. - * Example: - * Season 1: 10 episodes. - * Season 2: 6 episodes. - * - * getTotalEpisodeIndex(episode = 3, season = 2) -> 10 + 3 = 13 - * */ - fun getTotalEpisodeIndex(episode: Int, season: Int): Int } @JvmName("addSeasonNamesString") @@ -1358,55 +1373,7 @@ data class TorrentLoadResponse( override var syncData: MutableMap = mutableMapOf(), override var posterHeaders: Map? = null, override var backgroundPosterUrl: String? = null, - override var contentRating: String? = null, -) : LoadResponse { - /** - * Secondary constructor for backwards compatibility without contentRating. - * Remove this constructor after there is a new stable release and extensions are updated to support contentRating. - */ - constructor( - name: String, - url: String, - apiName: String, - magnet: String?, - torrent: String?, - plot: String?, - type: TvType = TvType.Torrent, - posterUrl: String? = null, - year: Int? = null, - rating: Int? = null, - tags: List? = null, - duration: Int? = null, - trailers: MutableList = mutableListOf(), - recommendations: List? = null, - actors: List? = null, - comingSoon: Boolean = false, - syncData: MutableMap = mutableMapOf(), - posterHeaders: Map? = null, - backgroundPosterUrl: String? = null, - ) : this( - name, - url, - apiName, - magnet, - torrent, - plot, - type, - posterUrl, - year, - rating, - tags, - duration, - trailers, - recommendations, - actors, - comingSoon, - syncData, - posterHeaders, - backgroundPosterUrl, - null - ) -} +) : LoadResponse data class AnimeLoadResponse( var engName: String? = null, @@ -1437,90 +1404,7 @@ data class AnimeLoadResponse( override var nextAiring: NextAiring? = null, override var seasonNames: List? = null, override var backgroundPosterUrl: String? = null, - override var contentRating: String? = null, -) : LoadResponse, EpisodeResponse { - override fun getLatestEpisodes(): Map { - return episodes.map { (status, episodes) -> - val maxSeason = episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE } - .takeUnless { it == Int.MIN_VALUE } - status to episodes - .filter { it.season == maxSeason } - .maxOfOrNull { it.episode ?: Int.MIN_VALUE } - .takeUnless { it == Int.MIN_VALUE } - }.toMap() - } - - override fun getTotalEpisodeIndex(episode: Int, season: Int): Int { - val displayMap = this.seasonNames?.associate { it.season to it.displaySeason } ?: emptyMap() - - return this.episodes.maxOf { (_, episodes) -> - episodes.count { episodeData -> - // Prioritize display season as actual season may be something random to fit multiple seasons into one. - val episodeSeason = - displayMap[episodeData.season] ?: episodeData.season ?: Int.MIN_VALUE - // Count all episodes from season 1 to below the current season. - episodeSeason in 1..> = mutableMapOf(), - showStatus: ShowStatus? = null, - plot: String? = null, - tags: List? = null, - synonyms: List? = null, - rating: Int? = null, - duration: Int? = null, - trailers: MutableList = mutableListOf(), - recommendations: List? = null, - actors: List? = null, - comingSoon: Boolean = false, - syncData: MutableMap = mutableMapOf(), - posterHeaders: Map? = null, - nextAiring: NextAiring? = null, - seasonNames: List? = null, - backgroundPosterUrl: String? = null, - ) : this( - engName, - japName, - name, - url, - apiName, - type, - posterUrl, - year, - episodes, - showStatus, - plot, - tags, - synonyms, - rating, - duration, - trailers, - recommendations, - actors, - comingSoon, - syncData, - posterHeaders, - nextAiring, - seasonNames, - backgroundPosterUrl, - null - ) -} +) : LoadResponse, EpisodeResponse /** * If episodes already exist appends the list. @@ -1571,36 +1455,7 @@ data class LiveStreamLoadResponse( override var syncData: MutableMap = mutableMapOf(), override var posterHeaders: Map? = null, override var backgroundPosterUrl: String? = null, - override var contentRating: String? = null, -) : LoadResponse { - /** - * Secondary constructor for backwards compatibility without contentRating. - * Remove this constructor after there is a new stable release and extensions are updated to support contentRating. - */ - constructor( - name: String, - url: String, - apiName: String, - dataUrl: String, - posterUrl: String? = null, - year: Int? = null, - plot: String? = null, - type: TvType = TvType.Live, - rating: Int? = null, - tags: List? = null, - duration: Int? = null, - trailers: MutableList = mutableListOf(), - recommendations: List? = null, - actors: List? = null, - comingSoon: Boolean = false, - syncData: MutableMap = mutableMapOf(), - posterHeaders: Map? = null, - backgroundPosterUrl: String? = null, - ) : this( - name, url, apiName, dataUrl, posterUrl, year, plot, type, rating, tags, duration, trailers, - recommendations, actors, comingSoon, syncData, posterHeaders, backgroundPosterUrl, null - ) -} +) : LoadResponse data class MovieLoadResponse( override var name: String, @@ -1623,36 +1478,7 @@ data class MovieLoadResponse( override var syncData: MutableMap = mutableMapOf(), override var posterHeaders: Map? = null, override var backgroundPosterUrl: String? = null, - override var contentRating: String? = null, -) : LoadResponse { - /** - * Secondary constructor for backwards compatibility without contentRating. - * Remove this constructor after there is a new stable release and extensions are updated to support contentRating. - */ - constructor( - name: String, - url: String, - apiName: String, - type: TvType, - dataUrl: String, - posterUrl: String? = null, - year: Int? = null, - plot: String? = null, - rating: Int? = null, - tags: List? = null, - duration: Int? = null, - trailers: MutableList = mutableListOf(), - recommendations: List? = null, - actors: List? = null, - comingSoon: Boolean = false, - syncData: MutableMap = mutableMapOf(), - posterHeaders: Map? = null, - backgroundPosterUrl: String? = null, - ) : this( - name, url, apiName, type, dataUrl, posterUrl, year, plot, rating, tags, duration, trailers, - recommendations, actors, comingSoon, syncData, posterHeaders, backgroundPosterUrl, null - ) -} +) : LoadResponse suspend fun MainAPI.newMovieLoadResponse( name: String, @@ -1700,17 +1526,7 @@ suspend fun MainAPI.newMovieLoadResponse( builder.initializer() return builder } -/** Episode information that will be passed to LoadLinks function & showed on UI - * @property data string used as main LoadLinks fun parameter. - * @property name Name of the Episode. - * @property season Season number. - * @property episode Episode number. - * @property posterUrl URL of Episode's poster image. - * @property rating Episode rating. - * @property date Episode air date, see addDate. - * @property runTime Episode runtime in seconds. - * @see[addDate] - * */ + data class Episode( var data: String, var name: String? = null, @@ -1720,25 +1536,7 @@ data class Episode( var rating: Int? = null, var description: String? = null, var date: Long? = null, - var runTime: Int? = null, -) { - /** - * Secondary constructor for backwards compatibility without runTime. - * TODO Remove this constructor after there is a new stable release and extensions are updated to support runTime. - */ - constructor( - data: String, - name: String? = null, - season: Int? = null, - episode: Int? = null, - posterUrl: String? = null, - rating: Int? = null, - description: String? = null, - date: Long? = null, - ) : this( - data, name, season, episode, posterUrl, rating, description, date, null - ) -} +) fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") { try { @@ -1780,28 +1578,6 @@ fun MainAPI.newEpisode( return builder } -interface IDownloadableMinimum { - val url: String - val referer: String - val headers: Map -} - -fun IDownloadableMinimum.getId(): Int { - return url.hashCode() -} - -/** - * Set of sync services simkl is compatible with. - * Add more as required: https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id - */ -enum class SimklSyncServices(val originalName: String) { - Simkl("simkl"), - Imdb("imdb"), - Tmdb("tmdb"), - AniList("anilist"), - Mal("mal"), -} - data class TvSeriesLoadResponse( override var name: String, override var url: String, @@ -1826,81 +1602,7 @@ data class TvSeriesLoadResponse( override var nextAiring: NextAiring? = null, override var seasonNames: List? = null, override var backgroundPosterUrl: String? = null, - override var contentRating: String? = null, -) : LoadResponse, EpisodeResponse { - override fun getLatestEpisodes(): Map { - val maxSeason = - episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }.takeUnless { it == Int.MIN_VALUE } - val max = episodes - .filter { it.season == maxSeason } - .maxOfOrNull { it.episode ?: Int.MIN_VALUE } - .takeUnless { it == Int.MIN_VALUE } - return mapOf(DubStatus.None to max) - } - - override fun getTotalEpisodeIndex(episode: Int, season: Int): Int { - val displayMap = this.seasonNames?.associate { it.season to it.displaySeason } ?: emptyMap() - - return episodes.count { episodeData -> - // Prioritize display season as actual season may be something random to fit multiple seasons into one. - val episodeSeason = - displayMap[episodeData.season] ?: episodeData.season ?: Int.MIN_VALUE - // Count all episodes from season 1 to below the current season. - episodeSeason in 1.., - posterUrl: String? = null, - year: Int? = null, - plot: String? = null, - showStatus: ShowStatus? = null, - rating: Int? = null, - tags: List? = null, - duration: Int? = null, - trailers: MutableList = mutableListOf(), - recommendations: List? = null, - actors: List? = null, - comingSoon: Boolean = false, - syncData: MutableMap = mutableMapOf(), - posterHeaders: Map? = null, - nextAiring: NextAiring? = null, - seasonNames: List? = null, - backgroundPosterUrl: String? = null, - ) : this( - name, - url, - apiName, - type, - episodes, - posterUrl, - year, - plot, - showStatus, - rating, - tags, - duration, - trailers, - recommendations, - actors, - comingSoon, - syncData, - posterHeaders, - nextAiring, - seasonNames, - backgroundPosterUrl, - null - ) -} +) : LoadResponse, EpisodeResponse suspend fun MainAPI.newTvSeriesLoadResponse( name: String, @@ -1940,43 +1642,30 @@ data class Tracker( val cover: String? = null, ) -data class AniSearch( - @JsonProperty("data") var data: Data? = Data() +data class Title( + @JsonProperty("romaji") val romaji: String? = null, + @JsonProperty("english") val english: String? = null, ) { - data class Data( - @JsonProperty("Page") var page: Page? = Page() - ) { - data class Page( - @JsonProperty("media") var media: ArrayList = arrayListOf() - ) { - data class Media( - @JsonProperty("title") var title: Title? = null, - @JsonProperty("id") var id: Int? = null, - @JsonProperty("idMal") var idMal: Int? = null, - @JsonProperty("seasonYear") var seasonYear: Int? = null, - @JsonProperty("format") var format: String? = null, - @JsonProperty("coverImage") var coverImage: CoverImage? = null, - @JsonProperty("bannerImage") var bannerImage: String? = null, - ) { - data class CoverImage( - @JsonProperty("extraLarge") var extraLarge: String? = null, - @JsonProperty("large") var large: String? = null, - ) - - data class Title( - @JsonProperty("romaji") var romaji: String? = null, - @JsonProperty("english") var english: String? = null, - ) { - fun isMatchingTitles(title: String?): Boolean { - if (title == null) return false - return english.equals(title, true) || romaji.equals(title, true) - } - } - } - } + fun isMatchingTitles(title: String?): Boolean { + if (title == null) return false + return english.equals(title, true) || romaji.equals(title, true) } } +data class Results( + @JsonProperty("id") val aniId: String? = null, + @JsonProperty("malId") val malId: Int? = null, + @JsonProperty("title") val title: Title? = null, + @JsonProperty("releaseDate") val releaseDate: Int? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("image") val image: String? = null, + @JsonProperty("cover") val cover: String? = null, +) + +data class AniSearch( + @JsonProperty("results") val results: ArrayList? = arrayListOf() +) + /** * used for the getTracker() method **/ diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 5408d2a8..eddec15e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1,36 +1,21 @@ 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.KeyEvent -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager +import android.view.* 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 @@ -41,119 +26,71 @@ import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager -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.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 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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.APIHolder.updateHasTrailers 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.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.mvvm.* 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.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.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.SettingsGeneral import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions -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.* +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.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 @@ -162,22 +99,21 @@ 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.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 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 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.math.abs -import kotlin.math.absoluteValue +import kotlin.reflect.KClass 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/ @@ -188,113 +124,112 @@ import kotlin.system.exitProcess //https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225 -class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { +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 { 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 @@ -303,7 +238,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * * This is a very bad solution but I was unable to find a better one. **/ - var nextSearchQuery: String? = null + private 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 @@ -319,16 +254,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // 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) @@ -340,8 +265,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa isWebview: Boolean ): Boolean = with(activity) { - // TODO MUCH BETTER HANDLING - // Invalid URIs can crash fun safeURI(uri: String) = normalSafeApiCall { URI(uri) } @@ -351,7 +274,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa println("Repository url: $realUrl") loadRepository(realUrl) return true - } else if (str.contains(APP_STRING)) { + } else if (str.contains(appString)) { for (api in OAuth2Apis) { if (str.contains("/${api.redirectUrl}")) { ioSafe { @@ -367,6 +290,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa this@with.runOnUiThread { try { showToast( + this@with, getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format( api.name ) @@ -381,45 +305,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } // 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 == "$APP_STRING:") { + if (str == "$appString:") { PluginManager.hotReloadAllLocalPlugins(activity) } - } else if (safeURI(str)?.scheme == APP_STRING_REPO) { - val url = str.replaceFirst(APP_STRING_REPO, "https") + } else if (safeURI(str)?.scheme == appStringRepo) { + val url = str.replaceFirst(appStringRepo, "https") loadRepository(url) return true - } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) { - val query = str.substringAfter("$APP_STRING_SEARCH://") + } else if (safeURI(str)?.scheme == appStringSearch) { nextSearchQuery = - 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) { + URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8") + nav_view.selectedItemId = R.id.navigation_search + } else if (safeURI(str)?.scheme == appStringResumeWatching) { val id = - str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull() + str.substringAfter("$appStringResumeWatching://").toIntOrNull() ?: return false ioSafe { val resumeWatchingCard = @@ -435,12 +334,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa this.navigate(R.id.navigation_downloads) return true } else { - synchronized(apis) { - for (api in apis) { - if (str.startsWith(api.mainUrl)) { - loadResult(str, api.name) - return true - } + for (api in apis) { + if (str.startsWith(api.mainUrl)) { + loadResult(str, api.name) + return true } } } @@ -451,30 +348,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } var lastPopup: SearchResponse? = null - fun loadPopup(result: SearchResponse, load: Boolean = true) { + fun loadPopup(result: SearchResponse) { lastPopup = result - 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) - } + viewModel.load( + this, result.url, result.apiName, false, if (getApiDubstatusSettings() + .contains(DubStatus.Dubbed) + ) DubStatus.Dubbed else DubStatus.Subbed, null + ) } override fun onColorSelected(dialogId: Int, color: Int) { @@ -488,7 +368,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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 @@ -499,7 +378,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa this.hideKeyboard() // Fucks up anime info layout since that has its own layout - binding?.castMiniControllerHolder?.isVisible = + cast_mini_controller_holder?.isVisible = !listOf( R.id.navigation_results_phone, R.id.navigation_results_tv, @@ -523,7 +402,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_settings_general, R.id.navigation_settings_extensions, R.id.navigation_settings_plugins, - R.id.navigation_test_providers, ).contains(destination.id) @@ -533,30 +411,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_results_phone, R.id.navigation_results_tv, R.id.navigation_player, - R.id.navigation_quick_search, ).contains(destination.id) - binding?.navHostFragment?.apply { + nav_host_fragment?.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 } @@ -564,53 +429,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa Configuration.ORIENTATION_LANDSCAPE -> { true } - Configuration.ORIENTATION_PORTRAIT -> { - isLayout(TV or EMULATOR) + false } - else -> { false } } - binding?.apply { - navRailView.isVisible = isNavVisible && landscape - navView.isVisible = isNavVisible && !landscape + nav_view?.isVisible = isNavVisible && !landscape + nav_rail_view?.isVisible = isNavVisible && landscape - /** - * 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 - } - } - } + // 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 } //private var mCastSession: CastSession? = null - var mSessionManager: SessionManager? = null + lateinit var mSessionManager: SessionManager private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() } private inner class SessionManagerListenerImpl : SessionManagerListener { @@ -647,10 +484,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::onAllPluginsLoaded - setActivityInstance(this) try { if (isCastApiAvailable()) { - mSessionManager?.addSessionManagerListener(mSessionManagerListener) + //mCastSession = mSessionManager.currentCastSession + mSessionManager.addSessionManagerListener(mSessionManagerListener) } } catch (e: Exception) { logError(e) @@ -666,7 +503,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } try { if (isCastApiAvailable()) { - mSessionManager?.removeSessionManagerListener(mSessionManagerListener) + mSessionManager.removeSessionManagerListener(mSessionManagerListener) //mCastSession = null } } catch (e: Exception) { @@ -674,10 +511,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val response = CommonActivity.dispatchKeyEvent(this, event) - if (response != null) - return response + + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { + CommonActivity.dispatchKeyEvent(this, event)?.let { + return it + } return super.dispatchKeyEvent(event) } @@ -704,16 +542,35 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa builder.show().setDefaultFocus() } - 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") - } + 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() } - filesToDelete = setOf() + } + + 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() { val broadcastIntent = Intent() broadcastIntent.action = "restart_service" broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) @@ -763,381 +620,73 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { - 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 - }) - } - } + // 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 + }) + } } - // 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] - syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java] + viewModel = + ViewModelProvider(this)[ResultViewModel2::class.java] return super.onCreateView(name, context, attrs) } private fun hidePreviewPopupDialog() { + viewModel.clear() bottomPreviewPopup.dismissSafe(this) - bottomPreviewPopup = null - bottomPreviewBinding = null } - private var bottomPreviewPopup: BottomSheetDialog? = null - private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null - private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding { - val ret = (bottomPreviewBinding ?: run { + var bottomPreviewPopup: BottomSheetDialog? = null + private fun showPreviewPopupDialog(): BottomSheetDialog { + val ret = (bottomPreviewPopup ?: run { val builder = BottomSheetDialog(this) - val binding: BottomResultviewPreviewBinding = - BottomResultviewPreviewBinding.inflate(builder.layoutInflater, null, false) - bottomPreviewBinding = binding - builder.setContentView(binding.root) + builder.setContentView(R.layout.bottom_resultview_preview) builder.setOnDismissListener { bottomPreviewPopup = null - bottomPreviewBinding = null viewModel.clear() } builder.setCanceledOnTouchOutside(true) builder.show() - bottomPreviewPopup = builder - binding + builder }) - + 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() @@ -1151,131 +700,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa super.onCreate(savedInstanceState) try { if (isCastApiAvailable()) { - CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager } + mSessionManager = CastContext.getSharedInstance(this).sessionManager } - } catch (t: Throwable) { - logError(t) + } catch (e: Exception) { + logError(e) } window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) updateTv() - - // 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) - } - } + if (isTvSettings()) { + setContentView(R.layout.activity_main_tv) + } else { + setContentView(R.layout.activity_main) } - // 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) + changeStatusBarState(isEmulatorSettings()) - 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(R.string.safe_mode_file, Toast.LENGTH_LONG) + showToast(this, R.string.safe_mode_file, Toast.LENGTH_LONG) } } else if (lastError == null) { ioSafe { - DataStoreHelper.currentHomePage?.let { homeApi -> + getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi -> mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi)) } ?: run { mainPluginsLoadedEvent.invoke(false) @@ -1292,18 +740,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa loadAllOnlinePlugins(this@MainActivity) } - //Automatically download not existing plugins, using mode specified. - val autoDownloadPlugin = AutoDownloadMode.getEnum( - settingsManager.getInt( + //Automatically download not existing plugins + if (settingsManager.getBoolean( getString(R.string.auto_download_plugins_key), - 0 - ) - ) ?: AutoDownloadMode.Disable - if (autoDownloadPlugin != AutoDownloadMode.Disable) { - PluginManager.downloadNotExistingPluginsAndLoad( - this@MainActivity, - autoDownloadPlugin + false ) + ) { + PluginManager.downloadNotExistingPluginsAndLoad(this@MainActivity) } } @@ -1328,184 +771,59 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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) { - hidePreviewPopupDialog() + bottomPreviewPopup.dismissSafe(this) return@observeNullable } when (resource) { is Resource.Failure -> { - showToast(R.string.error) - viewModel.clear() + showToast(this, R.string.error) hidePreviewPopupDialog() } - is Resource.Loading -> { showPreviewPopupDialog().apply { - resultviewPreviewLoading.isVisible = true - resultviewPreviewResult.isVisible = false - resultviewPreviewLoadingShimmer.startShimmer() + resultview_preview_loading?.isVisible = true + resultview_preview_result?.isVisible = false + resultview_preview_loading_shimmer?.startShimmer() } } - is Resource.Success -> { val d = resource.value showPreviewPopupDialog().apply { - resultviewPreviewLoading.isVisible = false - resultviewPreviewResult.isVisible = true - resultviewPreviewLoadingShimmer.stopShimmer() + resultview_preview_loading?.isVisible = false + resultview_preview_result?.isVisible = true + resultview_preview_loading_shimmer?.stopShimmer() - resultviewPreviewTitle.text = d.title + resultview_preview_title?.text = d.title - resultviewPreviewMetaType.setText(d.typeText) - resultviewPreviewMetaYear.setText(d.yearText) - resultviewPreviewMetaDuration.setText(d.durationText) - resultviewPreviewMetaRating.setText(d.ratingText) + 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) - resultviewPreviewDescription.setTextHtml(d.plotText) - resultviewPreviewPoster.setImage( + resultview_preview_description?.setText(d.plotText) + resultview_preview_poster?.setImage( d.posterImage ?: d.posterBackgroundImage ) - setUserData(syncViewModel.userData.value) - setWatchStatus(viewModel.watchStatus.value) - setSubscribeStatus(viewModel.subscribeStatus.value) - - resultviewPreviewBookmark.setOnClickListener { + resultview_preview_poster?.setOnClickListener { //viewModel.updateWatchStatus(WatchType.PLANTOWATCH) - if (isLocalList) { - val value = viewModel.watchStatus.value ?: WatchType.NONE + 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() - } + 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) } } - 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 -> + if (!isTvSettings()) // dont want this clickable on tv layout + resultview_preview_description?.setOnClickListener { view -> view.context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) @@ -1515,8 +833,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - resultviewPreviewMoreInfo.setOnClickListener { - viewModel.clear() + resultview_preview_more_info?.setOnClickListener { hidePreviewPopupDialog() lastPopup?.let { loadSearchResult(it) @@ -1550,26 +867,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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) @@ -1577,9 +874,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) - apis = synchronized(allProviders) { - allProviders.distinctBy { it } - } + apis = allProviders.distinctBy { it } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -1592,23 +887,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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) @@ -1621,47 +905,29 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa .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 - ) - } } - - 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) + nav_rail?.setOnItemSelectedListener { item -> + onNavDestinationSelected( + item, + navController + ) + } + nav_view?.setOnItemSelectedListener { item -> + onNavDestinationSelected( + item, + navController + ) + } + navController.addOnDestinationChangedListener { _, destination, _ -> + updateNavBar(destination) } loadCache() @@ -1684,12 +950,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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)) { @@ -1756,15 +1027,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - synchronized(allProviders) { - for (api in allProviders) { - providersAndroidManifestString += "\n" - } + for (api in allProviders) { + providersAndroidManifestString += "\n" } + println(providersAndroidManifestString) } @@ -1774,15 +1044,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa runAutoUpdate() } - FcastManager().init(this, false) - APIRepository.dubStatusActive = getApiDubstatusSettings() try { // this ensures that no unnecessary space is taken loadCache() File(filesDir, "exoplayer").deleteRecursively() // old cache - deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache + File(cacheDir, "exoplayer").deleteOnExit() // current cache } catch (e: Exception) { logError(e) } @@ -1792,11 +1060,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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) @@ -1812,6 +1075,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } catch (e: Exception) { logError(e) + } finally { + setKey(HAS_DONE_SETUP_KEY, true) } // Used to check current focus for TV @@ -1823,42 +1088,5 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // } // } - 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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt b/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt rename to app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt index 23f8dcf4..b0051ba7 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.api.Log +import android.util.Log import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt new file mode 100644 index 00000000..c782b29d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt @@ -0,0 +1,39 @@ +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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt index 4bed3169..7a62fb52 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt @@ -9,7 +9,7 @@ import java.net.URI open class AsianLoad : ExtractorApi() { override var name = "AsianLoad" - override var mainUrl = "https://asianhdplay.pro" + override var mainUrl = "https://asianembed.io" override val requiresReferer = true private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""") @@ -43,4 +43,4 @@ open class AsianLoad : ExtractorApi() { return extractedLinksList } } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Blogger.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Blogger.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Blogger.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Blogger.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/BullStream.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/BullStream.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt similarity index 92% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt index 2d56fe1f..3e0a03c0 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt @@ -4,7 +4,7 @@ import com.lagradost.cloudstream3.utils.* open class ByteShare : ExtractorApi() { override val name = "ByteShare" - override val mainUrl = "https://byteshare.to" + override val mainUrl = "https://byteshare.net" override val requiresReferer = false override suspend fun getUrl(url: String, referer: String?): List { @@ -20,4 +20,4 @@ open class ByteShare : ExtractorApi() { ) return sources } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt similarity index 94% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt index 42f6eddb..6a2f399d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Cda.kt @@ -1,11 +1,13 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import android.util.Log import java.net.URLDecoder open class Cda: ExtractorApi() { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt similarity index 60% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt index 2343a92e..125e4bcf 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt @@ -6,19 +6,13 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 +import com.lagradost.cloudstream3.utils.Qualities import java.net.URL -class Geodailymotion : Dailymotion() { - override val name = "GeoDailymotion" - override val mainUrl = "https://geo.dailymotion.com" -} - open class Dailymotion : ExtractorApi() { override val mainUrl = "https://www.dailymotion.com" override val name = "Dailymotion" override val requiresReferer = false - private val baseUrl = "https://www.dailymotion.com" @Suppress("RegExpSimplifiable") private val videoIdRegex = "^[kx][a-zA-Z0-9]+\$".toRegex() @@ -32,68 +26,68 @@ open class Dailymotion : ExtractorApi() { callback: (ExtractorLink) -> Unit ) { val embedUrl = getEmbedUrl(url) ?: return - val req = app.get(embedUrl) + val doc = app.get(embedUrl).document val prefix = "window.__PLAYER_CONFIG__ = " - val configStr = req.document.selectFirst("script:containsData($prefix)")?.data() ?: return - val config = tryParseJson(configStr.substringAfter(prefix).substringBefore(";").trim()) ?: return + val configStr = doc.selectFirst("script:containsData($prefix)")?.data() ?: return + val config = tryParseJson(configStr.substringAfter(prefix)) ?: return val id = getVideoId(embedUrl) ?: return val dmV1st = config.dmInternalData.v1st val dmTs = config.dmInternalData.ts - val embedder = config.context.embedder - val metaDataUrl = "$baseUrl/player/metadata/video/$id?embedder=$embedder&locale=en-US&dmV1st=$dmV1st&dmTs=$dmTs&is_native_app=0" - val metaData = app.get(metaDataUrl, referer = embedUrl, cookies = req.cookies) + val metaDataUrl = + "$mainUrl/player/metadata/video/$id?locale=en&dmV1st=$dmV1st&dmTs=$dmTs&is_native_app=0" + val cookies = mapOf( + "v1st" to dmV1st, + "dmvk" to config.context.dmvk, + "ts" to dmTs.toString() + ) + val metaData = app.get(metaDataUrl, referer = embedUrl, cookies = cookies) .parsedSafe() ?: return - metaData.qualities.forEach { (_, video) -> + metaData.qualities.forEach { (key, video) -> video.forEach { - getStream(it.url, this.name, callback) + callback.invoke( + ExtractorLink( + name, + "$name $key", + it.url, + "", + Qualities.Unknown.value, + true + ) + ) } } } private fun getEmbedUrl(url: String): String? { - if (url.contains("/embed/") || url.contains("/video/")) { - return url + if (url.contains("/embed/")) { + return url + } + val vid = getVideoId(url) ?: return null + return "$mainUrl/embed/video/$vid" } - if (url.contains("geo.dailymotion.com")) { - val videoId = url.substringAfter("video=") - return "$baseUrl/embed/video/$videoId" - } - return null - } private fun getVideoId(url: String): String? { val path = URL(url).path - val id = path.substringAfter("/video/") + val id = path.substringAfter("video/") if (id.matches(videoIdRegex)) { return id } return null } - private suspend fun getStream( - streamLink: String, - name: String, - callback: (ExtractorLink) -> Unit - ) { - return generateM3u8( - name, - streamLink, - "", - ).forEach(callback) - } data class Config( val context: Context, val dmInternalData: InternalData ) data class InternalData( - val ts: Long, + val ts: Int, val v1st: String ) data class Context( @JsonProperty("access_token") val accessToken: String?, - val embedder: String?, + val dmvk: String, ) data class MetaData( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt similarity index 65% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt index 370dcaca..7ec1fb22 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt @@ -7,22 +7,6 @@ import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.getQualityFromName import kotlinx.coroutines.delay -class D0000d : DoodLaExtractor() { - override var mainUrl = "https://d0000d.com" -} - -class D000dCom : DoodLaExtractor() { - override var mainUrl = "https://d000d.com" -} - -class DoodstreamCom : DoodLaExtractor() { - override var mainUrl = "https://doodstream.com" -} - -class Dooood : DoodLaExtractor() { - override var mainUrl = "https://dooood.com" -} - class DoodWfExtractor : DoodLaExtractor() { override var mainUrl = "https://dood.wf" } @@ -54,9 +38,6 @@ class DoodWsExtractor : DoodLaExtractor() { override var mainUrl = "https://dood.ws" } -class DoodYtExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.yt" -} open class DoodLaExtractor : ExtractorApi() { override var name = "DoodStream" @@ -68,14 +49,13 @@ open class DoodLaExtractor : ExtractorApi() { } override suspend fun getUrl(url: String, referer: String?): List? { - val newUrl= url.replace(mainUrl, "https://d0000d.com") - val response0 = app.get(newUrl).text // html of DoodStream page to look for /pass_md5/... - val md5 ="https://d0000d.com"+(Regex("/pass_md5/[^']*").find(response0)?.value ?: return null) // get https://dood.ws/pass_md5/... - val trueUrl = app.get(md5, referer = newUrl).text + "zUEJeL3mUN?token=" + md5.substringAfterLast("/") //direct link to extract (zUEJeL3mUN is random) + val response0 = app.get(url).text // html of DoodStream page to look for /pass_md5/... + val md5 =mainUrl+(Regex("/pass_md5/[^']*").find(response0)?.value ?: return null) // get https://dood.ws/pass_md5/... + val trueUrl = app.get(md5, referer = url).text + "zUEJeL3mUN?token=" + md5.substringAfterLast("/") //direct link to extract (zUEJeL3mUN is random) val quality = Regex("\\d{3,4}p").find(response0.substringAfter("").substringBefore(""))?.groupValues?.get(0) return listOf( ExtractorLink( - this.name, + trueUrl, this.name, trueUrl, mainUrl, diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Embedgram.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Embedgram.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Embedgram.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Embedgram.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Evolaod.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Evolaod.kt similarity index 66% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Evolaod.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Evolaod.kt index 3e38b446..eddbf6df 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Evolaod.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Evolaod.kt @@ -16,7 +16,26 @@ open class Evoload : ExtractorApi() { override suspend fun getUrl(url: String, referer: String?): List { - val id = url.replace("https://evoload.io/e/", "") // wanted media id + val lang = url.substring(0, 2) + val flag = + if (lang == "vo") { + " \uD83C\uDDEC\uD83C\uDDE7" + } + else if (lang == "vf"){ + " \uD83C\uDDE8\uD83C\uDDF5" + } else { + "" + } + + val cleaned_url = if (lang == "ht") { // if url doesn't contain a flag and the url starts with http:// + url + } else { + url.substring(2, url.length) + } + //println(lang) + //println(cleaned_url) + + val id = cleaned_url.replace("https://evoload.io/e/", "") // wanted media id val csrv_token = app.get("https://csrv.evosrv.com/captcha?m412548=").text // whatever that is val captchaPass = app.get("https://cd2.evosrv.com/html/jsx/e.jsx").text.take(300).split("captcha_pass = '")[1].split("\'")[0] //extract the captcha pass from the js response (located in the 300 first chars) val payload = mapOf("code" to id, "csrv_token" to csrv_token, "pass" to captchaPass) @@ -25,9 +44,9 @@ open class Evoload : ExtractorApi() { return listOf( ExtractorLink( name, - name, + name + flag, link, - url, + cleaned_url, Qualities.Unknown.value, ) ) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt new file mode 100644 index 00000000..bc910a7e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt @@ -0,0 +1,57 @@ +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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GMPlayer.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/GMPlayer.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt similarity index 58% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt index 8d1a4d07..df9c74a4 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt @@ -2,10 +2,14 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.extractors.helper.AesHelper.cryptoAESHandler import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import org.jsoup.nodes.Element +import java.security.DigestException +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec class DatabaseGdrive2 : Gdriveplayer() { override var mainUrl = "https://databasegdriveplayer.co" @@ -61,6 +65,78 @@ open class Gdriveplayer : ExtractorApi() { ?.data()?.let { getAndUnpack(it) } } + private fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } + + // https://stackoverflow.com/a/41434590/8166854 + private fun GenerateKeyAndIv( + password: ByteArray, + salt: ByteArray, + hashAlgorithm: String = "MD5", + keyLength: Int = 32, + ivLength: Int = 16, + iterations: Int = 1 + ): List? { + + val md = MessageDigest.getInstance(hashAlgorithm) + val digestLength = md.digestLength + val targetKeySize = keyLength + ivLength + val requiredLength = (targetKeySize + digestLength - 1) / digestLength * digestLength + val generatedData = ByteArray(requiredLength) + var generatedLength = 0 + + try { + md.reset() + + while (generatedLength < targetKeySize) { + if (generatedLength > 0) + md.update( + generatedData, + generatedLength - digestLength, + digestLength + ) + + md.update(password) + md.update(salt, 0, 8) + md.digest(generatedData, generatedLength, digestLength) + + for (i in 1 until iterations) { + md.update(generatedData, generatedLength, digestLength) + md.digest(generatedData, generatedLength, digestLength) + } + + generatedLength += digestLength + } + return listOf( + generatedData.copyOfRange(0, keyLength), + generatedData.copyOfRange(keyLength, targetKeySize) + ) + } catch (e: DigestException) { + return null + } + } + + private fun cryptoAESHandler( + data: AesData, + pass: ByteArray, + encrypt: Boolean = true + ): String? { + val (key, iv) = GenerateKeyAndIv(pass, data.s.decodeHex()) ?: return null + val cipher = Cipher.getInstance("AES/CBC/NoPadding") + return if (!encrypt) { + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + String(cipher.doFinal(base64DecodeArray(data.ct))) + } else { + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + base64Encode(cipher.doFinal(data.ct.toByteArray())) + + } + } + private fun Regex.first(str: String): String? { return find(str)?.groupValues?.getOrNull(1) } @@ -78,14 +154,14 @@ open class Gdriveplayer : ExtractorApi() { val document = app.get(url).document val eval = unpackJs(document)?.replace("\\", "") ?: return - val data = Regex("data='(\\S+?)'").first(eval) ?: return + val data = tryParseJson(Regex("data='(\\S+?)'").first(eval)) ?: return val password = Regex("null,['|\"](\\w+)['|\"]").first(eval) ?.split(Regex("\\D+")) ?.joinToString("") { Char(it.toInt()).toString() }.let { Regex("var pass = \"(\\S+?)\"").first(it ?: return)?.toByteArray() } ?: throw ErrorLoadingException("can't find password") - val decryptedData = cryptoAESHandler(data, password, false, "AES/CBC/NoPadding")?.let { getAndUnpack(it) }?.replace("\\", "") + val decryptedData = cryptoAESHandler(data, password, false)?.let { getAndUnpack(it) }?.replace("\\", "") val sourceData = decryptedData?.substringAfter("sources:[")?.substringBefore("],") val subData = decryptedData?.substringAfter("tracks:[")?.substringBefore("],") @@ -118,6 +194,12 @@ open class Gdriveplayer : ExtractorApi() { } + data class AesData( + @JsonProperty("ct") val ct: String, + @JsonProperty("iv") val iv: String, + @JsonProperty("s") val s: String + ) + data class Tracks( @JsonProperty("file") val file: String, @JsonProperty("kind") val kind: String, diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GenericM3U8.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GenericM3U8.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GenericM3U8.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/GenericM3U8.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt similarity index 98% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt index 3d046267..2adc00d5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt @@ -58,7 +58,7 @@ open class GuardareStream : ExtractorApi() { jsonVideoData.data.forEach { callback.invoke( ExtractorLink( - this.name, + it.file + ".${it.type}", this.name, it.file + ".${it.type}", mainUrl, diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Hxfile.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Hxfile.kt index bfd7cae5..f5dde774 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Hxfile.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson class Neonime7n : Hxfile() { override val name = "Neonime7n" - override val mainUrl = "https://neonime.fun" + override val mainUrl = "https://7njctn.neonime.watch" override val redirect = false } @@ -19,7 +19,7 @@ class Neonime8n : Hxfile() { class KotakAnimeid : Hxfile() { override val name = "KotakAnimeid" - override val mainUrl = "https://nontonanimeid.bio" + override val mainUrl = "https://kotakanimeid.com" override val requiresReferer = true } @@ -97,4 +97,4 @@ open class Hxfile : ExtractorApi() { @JsonProperty("label") val label: String? ) -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/JWPlayer.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/JWPlayer.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Jeniusplay.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Jeniusplay.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt new file mode 100644 index 00000000..c28a8900 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt @@ -0,0 +1,48 @@ +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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/M3u8Manifest.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/M3u8Manifest.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/M3u8Manifest.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/M3u8Manifest.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Maxstream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Maxstream.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Maxstream.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Maxstream.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/MixDrop.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/MixDrop.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Moviehab.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Moviehab.kt similarity index 96% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Moviehab.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Moviehab.kt index 51939cc2..aaa33ca1 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Moviehab.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Moviehab.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper class MoviehabNet : Moviehab() { - override var mainUrl = "https://play.moviehab.asia" + override var mainUrl = "https://play.moviehab.net" } open class Moviehab : ExtractorApi() { @@ -41,4 +41,4 @@ open class Moviehab : ExtractorApi() { } } } -} +} \ 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 new file mode 100644 index 00000000..93a280ed --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt @@ -0,0 +1,34 @@ +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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/MultiQuality.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/MultiQuality.kt index c7f4ac76..44657196 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/MultiQuality.kt @@ -9,7 +9,7 @@ import java.net.URI open class MultiQuality : ExtractorApi() { override var name = "MultiQuality" - override var mainUrl = "https://anihdplay.com" + override var mainUrl = "https://gogo-play.net" private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""") private val m3u8Regex = Regex(""".*?(\d*).m3u8""") private val urlRegex = Regex("""(.*?)([^/]+$)""") @@ -56,4 +56,4 @@ open class MultiQuality : ExtractorApi() { return extractedLinksList } } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mvidoo.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Mvidoo.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt new file mode 100644 index 00000000..70e87fbf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt @@ -0,0 +1,67 @@ +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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt index 4163cd94..45ec4c2f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt @@ -5,7 +5,6 @@ import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.loadExtractor @@ -67,7 +66,7 @@ open class Pelisplus(val mainUrl: String) { href, page.url, getQualityFromName(qual), - type = INFER_TYPE + element.attr("href").contains(".m3u8") ) ) } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PlayLtXyz.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/PlayLtXyz.kt similarity index 99% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PlayLtXyz.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/PlayLtXyz.kt index a4dc694e..2b286abb 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PlayLtXyz.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/PlayLtXyz.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.api.Log +import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PlayerVoxzer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/PlayerVoxzer.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PlayerVoxzer.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/PlayerVoxzer.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/SBPlay.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/SBPlay.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/SBPlay.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/SBPlay.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt similarity index 81% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt index 702501a1..8ef6c463 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt @@ -7,12 +7,14 @@ import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper -open class Minoplres : ExtractorApi() { +class SpeedoStream1 : SpeedoStream() { + override val mainUrl = "https://speedostream.nl" +} - override val name = "Minoplres" // formerly SpeedoStream +open class SpeedoStream : ExtractorApi() { + override val name = "SpeedoStream" + override val mainUrl = "https://speedostream.com" override val requiresReferer = true - override val mainUrl = "https://minoplres.xyz" // formerly speedostream.bond - private val hostUrl = "https://minoplres.xyz" override suspend fun getUrl(url: String, referer: String?): List { val sources = mutableListOf() @@ -24,7 +26,7 @@ open class Minoplres : ExtractorApi() { M3u8Helper.generateM3u8( name, it.file, - "$hostUrl/", + "$mainUrl/", ).forEach { m3uData -> sources.add(m3uData) } } } @@ -35,4 +37,6 @@ open class Minoplres : ExtractorApi() { private data class File( @JsonProperty("file") val file: String, ) -} + + +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamSB.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamSB.kt similarity index 66% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamSB.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/StreamSB.kt index df050cf3..958d63fb 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamSB.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamSB.kt @@ -6,51 +6,6 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper -import kotlin.random.Random - -class Sblona : StreamSB() { - override var name = "Sblona" - override var mainUrl = "https://sblona.com" -} - -class Lvturbo : StreamSB() { - override var name = "Lvturbo" - override var mainUrl = "https://lvturbo.com" -} - -class Sbrapid : StreamSB() { - override var name = "Sbrapid" - override var mainUrl = "https://sbrapid.com" -} - -class Sbface : StreamSB() { - override var name = "Sbface" - override var mainUrl = "https://sbface.com" -} - -class Sbsonic : StreamSB() { - override var name = "Sbsonic" - override var mainUrl = "https://sbsonic.com" -} - -class Vidgomunimesb : StreamSB() { - override var mainUrl = "https://vidgomunimesb.xyz" -} - -class Sbasian : StreamSB() { - override var mainUrl = "https://sbasian.pro" - override var name = "Sbasian" -} - -class Sbnet : StreamSB() { - override var name = "Sbnet" - override var mainUrl = "https://sbnet.one" -} - -class Keephealth : StreamSB() { - override var name = "Keephealth" - override var mainUrl = "https://keephealth.info" -} class Sbspeed : StreamSB() { override var name = "Sbspeed" @@ -122,70 +77,24 @@ class StreamSB10 : StreamSB() { override var mainUrl = "https://sbplay2.xyz" } -class StreamSB11 : StreamSB() { - override var mainUrl = "https://sbbrisk.com" -} - -class Sblongvu : StreamSB() { - override var mainUrl = "https://sblongvu.com" -} - +// This is a modified version of https://github.com/jmir1/aniyomi-extensions/blob/master/src/en/genoanime/src/eu/kanade/tachiyomi/animeextension/en/genoanime/extractors/StreamSBExtractor.kt +// The following code is under the Apache License 2.0 https://github.com/jmir1/aniyomi-extensions/blob/master/LICENSE open class StreamSB : ExtractorApi() { override var name = "StreamSB" override var mainUrl = "https://watchsb.com" override val requiresReferer = false - private val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val regexID = - Regex("(embed-[a-zA-Z\\d]{0,8}[a-zA-Z\\d_-]+|/e/[a-zA-Z\\d]{0,8}[a-zA-Z\\d_-]+)") - val id = regexID.findAll(url).map { - it.value.replace(Regex("(embed-|/e/)"), "") - }.first() - val master = "$mainUrl/375664356a494546326c4b797c7c6e756577776778623171737/${encodeId(id)}" - val headers = mapOf( - "watchsb" to "sbstream", - ) - val mapped = app.get( - master.lowercase(), - headers = headers, - referer = url, - ).parsedSafe
() - M3u8Helper.generateM3u8( - name, - mapped?.streamData?.file ?: return, - url, - headers = headers - ).forEach(callback) + private val hexArray = "0123456789ABCDEF".toCharArray() - mapped.streamData.subs?.map {sub -> - subtitleCallback.invoke( - SubtitleFile( - sub.label.toString(), - sub.file ?: return@map null, - ) - ) - } - } - - private fun encodeId(id: String): String { - val code = "${createHashTable()}||$id||${createHashTable()}||streamsb" - return code.toCharArray().joinToString("") { char -> - char.code.toString(16) - } - } - - private fun createHashTable(): String { - return buildString { - repeat(12) { - append(alphabet[Random.nextInt(alphabet.length)]) - } + private fun bytesToHex(bytes: ByteArray): String { + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + + hexChars[j * 2] = hexArray[v ushr 4] + hexChars[j * 2 + 1] = hexArray[v and 0x0F] } + return String(hexChars) } data class Subs ( @@ -209,4 +118,42 @@ open class StreamSB : ExtractorApi() { @JsonProperty("status_code") val statusCode: Int, ) -} + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val regexID = + Regex("(embed-[a-zA-Z0-9]{0,8}[a-zA-Z0-9_-]+|/e/[a-zA-Z0-9]{0,8}[a-zA-Z0-9_-]+)") + val id = regexID.findAll(url).map { + it.value.replace(Regex("(embed-|/e/)"), "") + }.first() +// val master = "$mainUrl/sources48/6d6144797752744a454267617c7c${bytesToHex.lowercase()}7c7c4e61755a56456f34385243727c7c73747265616d7362/6b4a33767968506e4e71374f7c7c343837323439333133333462353935333633373836643638376337633462333634663539343137373761333635313533333835333763376333393636363133393635366136323733343435323332376137633763373337343732363536313664373336327c7c504d754478413835306633797c7c73747265616d7362" + val master = "$mainUrl/sources50/" + bytesToHex("||$id||||streamsb".toByteArray()) + "/" + val headers = mapOf( + "watchsb" to "sbstream", + ) + val mapped = app.get( + master.lowercase(), + headers = headers, + referer = url, + ).parsedSafe
() + // val urlmain = mapped.streamData.file.substringBefore("/hls/") + M3u8Helper.generateM3u8( + name, + mapped?.streamData?.file ?: return, + url, + headers = headers + ).forEach(callback) + + mapped.streamData.subs?.map {sub -> + subtitleCallback.invoke( + SubtitleFile( + sub.label.toString(), + sub.file ?: return@map null, + ) + ) + } + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamTape.kt similarity index 93% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/StreamTape.kt index 2ee98c65..ece8dc4b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamTape.kt @@ -9,10 +9,6 @@ class StreamTapeNet : StreamTape() { override var mainUrl = "https://streamtape.net" } -class StreamTapeXyz : StreamTape() { - override var mainUrl = "https://streamtape.xyz" -} - class ShaveTape : StreamTape(){ override var mainUrl = "https://shavetape.cash" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Streamhub.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Streamhub.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamlare.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Streamlare.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamlare.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Streamlare.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Streamplay.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Streamplay.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt index e70cae6b..dd49d994 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt @@ -13,7 +13,7 @@ data class Files( open class Supervideo : ExtractorApi() { override var name = "Supervideo" - override var mainUrl = "https://supervideo.cc" + override var mainUrl = "https://supervideo.tv" override val requiresReferer = false override suspend fun getUrl(url: String, referer: String?): List? { val extractedLinksList: MutableList = mutableListOf() diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tantifilm.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Tantifilm.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tantifilm.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Tantifilm.kt index 13aa48c6..d721dea8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tantifilm.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Tantifilm.kt @@ -30,7 +30,7 @@ open class Tantifilm : ExtractorApi() { val jsonvideodata = parseJson(response) return jsonvideodata.data.map { ExtractorLink( - this.name, + it.file+".${it.type}", this.name, it.file+".${it.type}", mainUrl, diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/UpstreamExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/UpstreamExtractor.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/UpstreamExtractor.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/UpstreamExtractor.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt similarity index 57% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt index 86bd9e0b..5109acc3 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt @@ -7,10 +7,6 @@ class Uqload1 : Uqload() { override var mainUrl = "https://uqload.com" } -class Uqload2 : Uqload() { - override var mainUrl = "https://uqload.co" -} - open class Uqload : ExtractorApi() { override val name: String = "Uqload" override val mainUrl: String = "https://www.uqload.com" @@ -19,14 +15,30 @@ open class Uqload : ExtractorApi() { override suspend fun getUrl(url: String, referer: String?): List? { - with(app.get(url)) { // raised error ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED (3003) is due to the response: "error_nofile" + val lang = url.substring(0, 2) + val flag = + if (lang == "vo") { + " \uD83C\uDDEC\uD83C\uDDE7" + } + else if (lang == "vf"){ + " \uD83C\uDDE8\uD83C\uDDF5" + } else { + "" + } + + val cleaned_url = if (lang == "ht") { // if url doesn't contain a flag and the url starts with http:// + url + } else { + url.substring(2, url.length) + } + with(app.get(cleaned_url)) { // raised error ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED (3003) is due to the response: "error_nofile" srcRegex.find(this.text)?.groupValues?.get(1)?.replace("\"", "")?.let { link -> return listOf( ExtractorLink( name, - name, + name + flag, link, - url, + cleaned_url, Qualities.Unknown.value, ) ) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Userload.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Userload.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt new file mode 100644 index 00000000..a27bf188 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt @@ -0,0 +1,100 @@ +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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt similarity index 89% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt index 979fd8c5..615cfd74 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt @@ -25,13 +25,9 @@ open class Vidmoly : ExtractorApi() { subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit ) { - val headers = mapOf( - "User-Agent" to "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36", - "Sec-Fetch-Dest" to "iframe" - ) + val script = app.get( url, - headers = headers, referer = referer, ).document.select("script") .find { it.data().contains("sources:") }?.data() @@ -70,4 +66,4 @@ open class Vidmoly : ExtractorApi() { @JsonProperty("kind") val kind: String? = null, ) -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt index c6493dbe..7eb7fbac 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt @@ -5,7 +5,6 @@ import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.argamap import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.loadExtractor @@ -71,7 +70,7 @@ class Vidstream(val mainUrl: String) { href, page.url, getQualityFromName(qual), - type = INFER_TYPE + element.attr("href").contains(".m3u8") ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt new file mode 100644 index 00000000..12a76a9b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt @@ -0,0 +1,32 @@ +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 new file mode 100644 index 00000000..ad3f0150 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt @@ -0,0 +1,54 @@ +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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WatchSB.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/WatchSB.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WatchSB.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/WatchSB.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/WcoStream.kt similarity index 96% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/WcoStream.kt index 659d7804..6cc486cd 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/WcoStream.kt @@ -7,7 +7,6 @@ import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.encrypt import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.Qualities class Vidstreamz : WcoStream() { @@ -127,7 +126,8 @@ open class WcoStream : ExtractorApi() { if (!response.text.startsWith("{")) throw ErrorLoadingException("Seems like 9Anime kiddies changed stuff again, Go touch some grass for bout an hour Or use a different Server") return response.parsed().data.media.sources.map { - ExtractorLink(name, it.file, it.file, host, Qualities.Unknown.value, type = INFER_TYPE) + ExtractorLink(name, it.file,it.file,host,Qualities.Unknown.value,it.file.contains(".m3u8")) } + } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/XStreamCdn.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/XStreamCdn.kt similarity index 94% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/XStreamCdn.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/XStreamCdn.kt index ccb2fde7..15ff0436 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/XStreamCdn.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/XStreamCdn.kt @@ -8,16 +8,6 @@ import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.getQualityFromName -class StreamM4u : XStreamCdn() { - override val name: String = "StreamM4u" - override val mainUrl: String = "https://streamm4u.club" -} - -class Fembed9hd : XStreamCdn() { - override var mainUrl = "https://fembed9hd.com" - override var name = "Fembed9hd" -} - class Cdnplayer: XStreamCdn() { override val name: String = "Cdnplayer" override val mainUrl: String = "https://cdnplayer.online" diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YourUpload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/YourUpload.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YourUpload.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/YourUpload.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt similarity index 94% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt index 4e854630..23704e90 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt @@ -70,18 +70,19 @@ open class YoutubeExtractor : ExtractorApi() { } } ytVideos[url]?.mapNotNull { - if (it.isVideoOnly() || it.height <= 0) return@mapNotNull null + if (it.isVideoOnly || it.height <= 0) return@mapNotNull null ExtractorLink( this.name, this.name, - it.content ?: return@mapNotNull null, + it.url ?: return@mapNotNull null, "", it.height ) }?.forEach(callback) ytVideosSubtitles[url]?.mapNotNull { - SubtitleFile(it.languageTag ?: return@mapNotNull null, it.content ?: return@mapNotNull null) + SubtitleFile(it.languageTag ?: return@mapNotNull null, it.url ?: return@mapNotNull null) }?.forEach(subtitleCallback) } + } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zplayer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Zplayer.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zplayer.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/Zplayer.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AsianEmbedHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/helper/AsianEmbedHelper.kt similarity index 97% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AsianEmbedHelper.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/helper/AsianEmbedHelper.kt index bd42424f..0b401c06 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AsianEmbedHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/helper/AsianEmbedHelper.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.extractors.helper -import com.lagradost.api.Log +import android.util.Log import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/helper/NineAnimeHelper.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/VstreamhubHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/helper/VstreamhubHelper.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/VstreamhubHelper.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/helper/VstreamhubHelper.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/WcoHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/helper/WcoHelper.kt similarity index 76% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/WcoHelper.kt rename to app/src/main/java/com/lagradost/cloudstream3/extractors/helper/WcoHelper.kt index 35aec2b1..768fa1f6 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/WcoHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/helper/WcoHelper.kt @@ -1,6 +1,8 @@ package com.lagradost.cloudstream3.extractors.helper import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.app class WcoHelper { @@ -28,7 +30,9 @@ class WcoHelper { private suspend fun getKeys() { keys = keys ?: app.get("https://raw.githubusercontent.com/reduplicated/Cloudstream/master/docs/keys.json") - .parsedSafe() + .parsedSafe()?.also { setKey(BACKUP_KEY_DATA, it) } ?: getKey( + BACKUP_KEY_DATA + ) } suspend fun getWcoKey(): ExternalKeys? { @@ -39,7 +43,9 @@ class WcoHelper { private suspend fun getNewKeys() { newKeys = newKeys ?: app.get("https://raw.githubusercontent.com/chekaslowakiya/BruhFlow/main/keys.json") - .parsedSafe() + .parsedSafe()?.also { setKey(BACKUP_KEY_DATA, it) } ?: getKey( + BACKUP_KEY_DATA + ) } suspend fun getNewWcoKey(): NewExternalKeys? { 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 5bbb4538..07aa904e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt @@ -21,11 +21,10 @@ class CrossTmdbProvider : TmdbProvider() { return Regex("""[^a-zA-Z0-9-]""").replace(name, "") } - private val validApis - get() = - synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } } - //.distinctBy { it.uniqueId } - + private val validApis by lazy { + apis.filter { it.lang == this.lang && it::class.java != this::class.java } + //.distinctBy { it.uniqueId } + } data class CrossMetaData( @JsonProperty("isSuccess") val isSuccess: Boolean, @@ -61,8 +60,7 @@ 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 -> { @@ -100,7 +98,6 @@ 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 new file mode 100644 index 00000000..e8ac1876 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt @@ -0,0 +1,70 @@ +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 bc646a8d..75e96bec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt @@ -2,13 +2,15 @@ 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 c5b4d453..314177af 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt @@ -105,7 +105,6 @@ open class TmdbProvider : MainAPI() { this.id, episode.episode_number, episode.season_number, - this.name ?: this.original_name, ).toJson(), episode.name, episode.season_number, @@ -123,7 +122,6 @@ open class TmdbProvider : MainAPI() { this.id, episodeNum, season.season_number, - this.name ?: this.original_name, ).toJson(), season = season.season_number ) @@ -153,8 +151,6 @@ open class TmdbProvider : MainAPI() { recommendations = (this@toLoadResponse.recommendations ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } addActors(credits?.cast?.toList().toActors()) - - contentRating = fetchContentRating(id, "US") } } @@ -197,8 +193,6 @@ open class TmdbProvider : MainAPI() { recommendations = (this@toLoadResponse.recommendations ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } addActors(credits?.cast?.toList().toActors()) - - contentRating = fetchContentRating(id, "US") } } @@ -270,26 +264,6 @@ 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 deleted file mode 100644 index addee9a0..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ /dev/null @@ -1,471 +0,0 @@ -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/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt similarity index 50% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt rename to app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt index d3b4999a..afe956cc 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt @@ -1,7 +1,10 @@ package com.lagradost.cloudstream3.mvvm -import com.lagradost.api.BuildConfig -import com.lagradost.api.Log +import android.util.Log +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import com.bumptech.glide.load.HttpException +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.ErrorLoadingException import kotlinx.coroutines.* import java.io.InterruptedIOException @@ -46,6 +49,40 @@ inline fun debugWarning(assert: () -> Boolean, message: () -> String) { } } +fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { + liveData.observe(this) { it?.let { t -> action(t) } } +} + +fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { + liveData.observe(this) { action(it) } +} + +inline fun some(value: T?): Some { + return if (value == null) { + Some.None + } else { + Some.Success(value) + } +} + +sealed class Some { + data class Success(val value: T) : Some() + object None : Some() + + override fun toString(): String { + return when (this) { + is None -> "None" + is Success -> "Some(${value.toString()})" + } + } +} + +sealed class ResourceSome { + data class Success(val value: T) : ResourceSome() + object None : ResourceSome() + data class Loading(val data: Any? = null) : ResourceSome() +} + sealed class Resource { data class Success(val value: T) : Resource() data class Failure( @@ -84,21 +121,13 @@ suspend fun suspendSafeApiCall(apiCall: suspend () -> T): T? { } } -fun Throwable.getAllMessages(): String { - return (this.localizedMessage ?: "") + (this.cause?.getAllMessages()?.let { "\n$it" } ?: "") -} - -fun Throwable.getStackTracePretty(showMessage: Boolean = true): String { - val prefix = if (showMessage) this.localizedMessage?.let { "\n$it" } ?: "" else "" - return prefix + this.stackTrace.joinToString( - separator = "\n" - ) { - "${it.fileName} ${it.lineNumber}" - } -} - fun safeFail(throwable: Throwable): Resource { - val stackTraceMsg = throwable.getStackTracePretty() + val stackTraceMsg = + (throwable.localizedMessage ?: "") + "\n\n" + throwable.stackTrace.joinToString( + separator = "\n" + ) { + "${it.fileName} ${it.lineNumber}" + } return Resource.Failure(false, null, null, stackTraceMsg) } @@ -118,70 +147,6 @@ fun CoroutineScope.launchSafe( return this.launch(context, start, obj) } -fun throwAbleToResource( - throwable: Throwable -): Resource { - return when (throwable) { - is NullPointerException -> { - for (line in throwable.stackTrace) { - if (line?.fileName?.endsWith("provider.kt", ignoreCase = true) == true) { - return Resource.Failure( - false, - null, - null, - "NullPointerException at ${line.fileName} ${line.lineNumber}\nSite might have updated or added Cloudflare/DDOS protection" - ) - } - } - safeFail(throwable) - } - is SocketTimeoutException, is InterruptedIOException -> { - Resource.Failure( - true, - null, - null, - "Connection Timeout\nPlease try again later." - ) - } -// is HttpException -> { -// Resource.Failure( -// false, -// throwable.statusCode, -// null, -// throwable.message ?: "HttpException" -// ) -// } - is UnknownHostException -> { - Resource.Failure(true, null, null, "Cannot connect to server, try again later.\n${throwable.message}") - } - is ErrorLoadingException -> { - Resource.Failure( - true, - null, - null, - throwable.message ?: "Error loading, try again later." - ) - } - is NotImplementedError -> { - Resource.Failure(false, null, null, "This operation is not implemented.") - } - is SSLHandshakeException -> { - Resource.Failure( - true, - null, - null, - (throwable.message ?: "SSLHandshakeException") + "\nTry a VPN or DNS." - ) - } - is CancellationException -> { - throwable.cause?.let { - throwAbleToResource(it) - } ?: safeFail(throwable) - } - else -> safeFail(throwable) - } -} - suspend fun safeApiCall( apiCall: suspend () -> T, ): Resource { @@ -190,7 +155,60 @@ suspend fun safeApiCall( Resource.Success(apiCall.invoke()) } catch (throwable: Throwable) { logError(throwable) - throwAbleToResource(throwable) + when (throwable) { + is NullPointerException -> { + for (line in throwable.stackTrace) { + if (line?.fileName?.endsWith("provider.kt", ignoreCase = true) == true) { + return@withContext Resource.Failure( + false, + null, + null, + "NullPointerException at ${line.fileName} ${line.lineNumber}\nSite might have updated or added Cloudflare/DDOS protection" + ) + } + } + safeFail(throwable) + } + is SocketTimeoutException, is InterruptedIOException -> { + Resource.Failure( + true, + null, + null, + "Connection Timeout\nPlease try again later." + ) + } + is HttpException -> { + Resource.Failure( + false, + throwable.statusCode, + null, + throwable.message ?: "HttpException" + ) + } + is UnknownHostException -> { + Resource.Failure(true, null, null, "Cannot connect to server, try again later.") + } + is ErrorLoadingException -> { + Resource.Failure( + true, + null, + null, + throwable.message ?: "Error loading, try again later." + ) + } + is NotImplementedError -> { + Resource.Failure(false, null, null, "This operation is not implemented.") + } + is SSLHandshakeException -> { + Resource.Failure( + true, + null, + null, + (throwable.message ?: "SSLHandshakeException") + "\nTry a VPN or DNS." + ) + } + else -> safeFail(throwable) + } } } } \ 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 deleted file mode 100644 index 3df5197c..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt +++ /dev/null @@ -1,16 +0,0 @@ -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 85a9db5d..6950d961 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -9,10 +9,7 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking -import okhttp3.Headers -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response +import okhttp3.* import java.net.URI @@ -20,8 +17,6 @@ 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("=") @@ -53,23 +48,15 @@ class CloudflareKiller : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() + val cookies = savedCookies[request.url.host] - 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) + if (cookies == null) { + bypassCloudflare(request)?.let { + Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") + return@runBlocking it } + } else { + return@runBlocking proceed(request, cookies) } debugWarning({ true }) { "Failed cloudflare at: ${request.url}" } diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt b/app/src/main/java/com/lagradost/cloudstream3/network/WebViewResolver.kt similarity index 91% rename from library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt rename to app/src/main/java/com/lagradost/cloudstream3/network/WebViewResolver.kt index 0fbc5749..9171aed9 100644 --- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/WebViewResolver.kt @@ -1,12 +1,11 @@ package com.lagradost.cloudstream3.network import android.annotation.SuppressLint -import android.content.Context import android.net.http.SslError -import android.os.Handler -import android.os.Looper import android.webkit.* -import com.lagradost.api.getContext +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError @@ -28,28 +27,21 @@ import java.net.URI * @param additionalUrls this will make resolveUsingWebView also return all other requests matching the list of Regex. * @param userAgent if null then will use the default user agent * @param useOkhttp will try to use the okhttp client as much as possible, but this might cause some requests to fail. Disable for cloudflare. - * @param script pass custom js to execute - * @param scriptCallback will be called with the result from custom js - * @param timeout close webview after timeout * */ -actual class WebViewResolver actual constructor( +class WebViewResolver( val interceptUrl: Regex, - val additionalUrls: List, - val userAgent: String?, - val useOkhttp: Boolean, - val script: String?, - val scriptCallback: ((String) -> Unit)?, - val timeout: Long + val additionalUrls: List = emptyList(), + val userAgent: String? = USER_AGENT, + val useOkhttp: Boolean = true ) : Interceptor { - actual companion object { + companion object { var webViewUserAgent: String? = null - actual val DEFAULT_TIMEOUT = 60_000L @JvmName("getWebViewUserAgent1") fun getWebViewUserAgent(): String? { - return webViewUserAgent ?: (getContext() as? Context)?.let { ctx -> + return webViewUserAgent ?: context?.let { ctx -> runBlocking { mainWork { WebView(ctx).settings.userAgentString.also { userAgent -> @@ -120,7 +112,7 @@ actual class WebViewResolver actual constructor( WebView.setWebContentsDebuggingEnabled(true) try { webView = WebView( - (getContext() as? Context) + AcraApplication.context ?: throw RuntimeException("No base context in WebViewResolver") ).apply { // Bare minimum to bypass captcha @@ -144,14 +136,6 @@ actual class WebViewResolver actual constructor( val webViewUrl = request.url.toString() println("Loading WebView URL: $webViewUrl") - if (script != null) { - val handler = Handler(Looper.getMainLooper()) - handler.post { - view.evaluateJavascript("$script") - { scriptCallback?.invoke(it) } - } - } - if (interceptUrl.containsMatchIn(webViewUrl)) { fixedRequest = request.toRequest()?.also { requestCallBack(it) @@ -257,7 +241,7 @@ actual class WebViewResolver actual constructor( var loop = 0 // Timeouts after this amount, 60s - val totalTime = timeout + val totalTime = 60000L val delayTime = 100L 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 ddf5b286..e89ccfeb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt @@ -2,4 +2,5 @@ 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 fc836587..242baf59 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -34,11 +34,9 @@ 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 - synchronized(APIHolder.allProviders) { - APIHolder.allProviders.add(element) - } + APIHolder.allProviders.add(element) APIHolder.addPluginMapping(element) } @@ -48,31 +46,22 @@ 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 - /** 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 + 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 bc2a1780..3533d6a8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -1,25 +1,23 @@ 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 @@ -35,7 +33,6 @@ 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 @@ -139,20 +136,6 @@ 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() } @@ -166,7 +149,7 @@ object PluginManager { private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins" - var currentlyLoading: String? = null + public var currentlyLoading: String? = null // Maps filepath to plugin val plugins: MutableMap = @@ -179,18 +162,14 @@ object PluginManager { private val classLoaders: MutableMap = HashMap() - var loadedLocalPlugins = false - private set - - var loadedOnlinePlugins = false - private set + private var loadedLocalPlugins = false private val gson = Gson() - private suspend fun maybeLoadPlugin(context: Context, file: File) { + private suspend fun maybeLoadPlugin(activity: Activity, file: File) { val name = file.name if (file.extension == "zip" || file.extension == "cs3") { loadPlugin( - context, + activity, file, PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET) ) @@ -220,7 +199,7 @@ object PluginManager { // var allCurrentOutDatedPlugins: Set = emptySet() - suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean { + suspend fun loadSinglePlugin(activity: Activity, 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 @@ -230,7 +209,7 @@ object PluginManager { })?.let { savedData -> // OnlinePluginData(savedData, onlineData) loadPlugin( - context, + activity, File(savedData.filePath), savedData ) @@ -297,7 +276,6 @@ object PluginManager { } // ioSafe { - loadedOnlinePlugins = true afterPluginsLoadedEvent.invoke(false) // } @@ -310,7 +288,7 @@ object PluginManager { * 2. Fetch all not downloaded plugins * 3. Download them and reload plugins **/ - fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) { + fun downloadNotExistingPluginsAndLoad(activity: Activity) { val newDownloadPlugins = mutableListOf() val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES @@ -324,8 +302,6 @@ 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 @@ -340,29 +316,22 @@ object PluginManager { 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 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 + } + } + } val savedData = PluginData( url = sitePlugin.url, internalName = sitePlugin.internalName, @@ -402,11 +371,11 @@ object PluginManager { /** * Use updateAllOnlinePluginsAndLoadThem * */ - fun loadAllOnlinePlugins(context: Context) { + fun loadAllOnlinePlugins(activity: Activity) { // Load all plugins as fast as possible! (getPluginsOnline()).toList().apmap { pluginData -> loadPlugin( - context, + activity, File(pluginData.filePath), pluginData ) @@ -429,8 +398,9 @@ 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(context: Context, forceReload: Boolean) { + fun loadAllLocalPlugins(activity: Activity, forceReload: Boolean) { val dir = File(LOCAL_PLUGINS_PATH) + removeKey(PLUGINS_KEY_LOCAL) if (!dir.exists()) { val res = dir.mkdirs() @@ -446,7 +416,7 @@ object PluginManager { Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins") sortedPlugins?.sortedBy { it.name }?.apmap { file -> - maybeLoadPlugin(context, file) + maybeLoadPlugin(activity, file) } loadedLocalPlugins = true @@ -471,22 +441,14 @@ object PluginManager { /** * @return True if successful, false if not * */ - private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { + private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean { val fileName = file.nameWithoutExtension val filePath = file.absolutePath currentlyLoading = fileName Log.i(TAG, "Loading plugin: $data") return try { - // 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) + val loader = PathClassLoader(filePath, activity.classLoader) var manifest: Plugin.Manifest loader.getResourceAsStream("manifest.json").use { stream -> if (stream == null) { @@ -507,12 +469,10 @@ 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.getDeclaredConstructor().newInstance() as Plugin + pluginClass.newInstance() as Plugin // Sets with the proper version setPluginData(data.copy(version = version)) @@ -522,34 +482,32 @@ object PluginManager { return true } - pluginInstance.filename = file.absolutePath + pluginInstance.__filename = fileName 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.getDeclaredConstructor().newInstance() + val assets = AssetManager::class.java.newInstance() val addAssetPath = AssetManager::class.java.getMethod("addAssetPath", String::class.java) addAssetPath.invoke(assets, file.absolutePath) - - @Suppress("DEPRECATION") pluginInstance.resources = Resources( assets, - context.resources.displayMetrics, - context.resources.configuration + activity.resources.displayMetrics, + activity.resources.configuration ) } plugins[filePath] = pluginInstance classLoaders[loader] = pluginInstance urlPlugins[data.url ?: filePath] = pluginInstance - pluginInstance.load(context) + pluginInstance.load(activity) 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( - context.getActivity(), - context.getString(R.string.plugin_load_fail).format(fileName), + activity, + activity.getString(R.string.plugin_load_fail).format(fileName), Toast.LENGTH_LONG ) currentlyLoading = null @@ -572,15 +530,11 @@ object PluginManager { } // remove all registered apis - synchronized(APIHolder.apis) { - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { - removePluginMapping(it) - } + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { + removePluginMapping(it) } - synchronized(APIHolder.allProviders) { - APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } - } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename } + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename } classLoaders.values.removeIf { v -> v == plugin } @@ -727,14 +681,9 @@ object PluginManager { } val notification = builder.build() - // 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) + with(NotificationManagerCompat.from(context)) { + // notificationId is a unique int for each notification that you must define + notify((System.currentTimeMillis() / 1000).toInt(), notification) } return notification } catch (e: Exception) { @@ -742,4 +691,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 c6ec9df7..e77b2d54 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -2,10 +2,8 @@ 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 @@ -73,15 +71,6 @@ 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() @@ -95,11 +84,10 @@ object RepositoryManager { } } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { suspendSafeApiCall { - 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 + 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 } } } @@ -109,14 +97,14 @@ object RepositoryManager { suspend fun parseRepository(url: String): Repository? { return suspendSafeApiCall { // Take manifestVersion and such into account later - app.get(convertRawGitUrl(url)).parsedSafe() + app.get(url).parsedSafe() } } private suspend fun parsePlugins(pluginUrls: String): List { // Take manifestVersion and such into account later return try { - val response = app.get(convertRawGitUrl(pluginUrls)) + val response = app.get(pluginUrls) // Normal parsed function not working? // return response.parsedSafe() tryParseJson>(response.text)?.toList() ?: emptyList() @@ -151,7 +139,7 @@ object RepositoryManager { } file.createNewFile() - val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body + val body = app.get(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 d1b702f4..f099ad1a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -8,14 +8,22 @@ 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" - private const val API_DOMAIN = "https://counterapi.com/api" + enum class VoteType(val value: Int) { + UPVOTE(1), + DOWNVOTE(-1), + NONE(0) + } + + private val apiDomain = "https://api.countapi.xyz" private fun transformUrl(url: String): String = // dont touch or all votes get reset MessageDigest @@ -27,12 +35,12 @@ object VotingApi { // please do not cheat the votes lol return getVotes(url) } - fun SitePlugin.hasVoted(): Boolean { - return hasVoted(url) + suspend fun SitePlugin.vote(requestType: VoteType): Int { + return vote(url, requestType) } - suspend fun SitePlugin.vote(): Int { - return vote(url) + fun SitePlugin.getVoteType(): VoteType { + return getVoteType(url) } fun SitePlugin.canVote(): Boolean { @@ -42,38 +50,36 @@ object VotingApi { // please do not cheat the votes lol // Plugin url to Int private val votesCache = mutableMapOf() - 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" + suspend fun getVotes(pluginUrl: String): Int { + val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}" Log.d(LOGKEY, "Requesting: $url") - 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 + return votesCache[pluginUrl] ?: app.get(url).parsedSafe()?.value?.also { + votesCache[pluginUrl] = it + } ?: (0.also { + ioSafe { + createBucket(pluginUrl) } + }) + } - fun hasVoted(pluginUrl: String) = - getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false + 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 canVote(pluginUrl: String): Boolean { - return PluginManager.urlPlugins.contains(pluginUrl) + if (!PluginManager.urlPlugins.contains(pluginUrl)) return false + return true } private val voteLock = Mutex() - suspend fun vote(pluginUrl: String): Int { + suspend fun vote(pluginUrl: String, requestType: VoteType): Int { // Prevent multiple requests at the same time. voteLock.withLock { if (!canVote(pluginUrl)) { @@ -84,21 +90,33 @@ object VotingApi { // please do not cheat the votes lol return getVotes(pluginUrl) } - if (hasVoted(pluginUrl)) { - main { - Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) - .show() - } - 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 (writeVote(pluginUrl)) { - setKey("cs3-votes/${transformUrl(pluginUrl)}", true) - votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 - } - - return getVotes(pluginUrl) + return res ?: 0 } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt deleted file mode 100644 index 4ef841f5..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 00c74dff..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ /dev/null @@ -1,235 +0,0 @@ -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 6151a0ed..be2fe75b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -1,22 +1,11 @@ package com.lagradost.cloudstream3.services -import android.app.Service + +import android.app.IntentService 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 : 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 { +class VideoDownloadService : IntentService("VideoDownloadService") { + override fun onHandleIntent(intent: Intent?) { if (intent != null) { val id = intent.getIntExtra("id", -1) val type = intent.getStringExtra("type") @@ -25,36 +14,10 @@ class VideoDownloadService : Service() { "resume" -> VideoDownloadManager.DownloadActionType.Resume "pause" -> VideoDownloadManager.DownloadActionType.Pause "stop" -> VideoDownloadManager.DownloadActionType.Stop - else -> return START_NOT_STICKY - } - - downloadScope.launch { - VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) + else -> return } + VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) } } - - return START_NOT_STICKY } - - 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)) -// } -// } -// } -//} +} \ No newline at end of file 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 df64caab..77a1b0b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,23 +1,11 @@ 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() @@ -27,98 +15,6 @@ 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 685b499b..f6424c4c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt @@ -19,11 +19,8 @@ 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 2e14c3c4..f17086c1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -3,75 +3,61 @@ 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).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 malApi = MALApi(0) + val aniListApi = AniListApi(0) 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, simklApi + malApi, aniListApi ) // this needs init with context and can be accessed in settings val accountManagers get() = listOf( - malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi + malApi, aniListApi, openSubtitlesApi, //nginxApi ) // used for active syncing val SyncApis get() = listOf( - SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi) + SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi) ) val inAppAuths - get() = listOf( - openSubtitlesApi, - subDlApi - )//, nginxApi) + get() = listOf(openSubtitlesApi)//, nginxApi) val subtitleProviders get() = listOf( openSubtitlesApi, - addic7ed, - subDlApi, - subSourceApi + indexSubtitlesApi, // they got anti scraping measures in place :( + addic7ed ) - const val APP_STRING = "cloudstreamapp" - const val APP_STRING_REPO = "cloudstreamrepo" - const val APP_STRING_PLAYER = "cloudstreamplayer" + const val appString = "cloudstreamapp" + const val appStringRepo = "cloudstreamrepo" // Instantly start the search given a query - const val APP_STRING_SEARCH = "cloudstreamsearch" + const val appStringSearch = "cloudstreamsearch" // Instantly resume watching a show - const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" + const val appStringResumeWatching = "cloudstreamcontinuewatching" val unixTime: Long get() = System.currentTimeMillis() / 1000L val unixTimeMs: Long get() = System.currentTimeMillis() - const val MAX_STALE = 60 * 10 + const val maxStale = 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 3d0bb940..ef74edfc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt @@ -5,23 +5,7 @@ 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 dcb8bbea..8c76c5bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -1,11 +1,17 @@ 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 -import java.util.Date + +enum class SyncIdName { + Anilist, + MyAnimeList, + Trakt, + Imdb, + LocalList +} interface SyncAPI : OAuth2API { /** @@ -29,9 +35,9 @@ interface SyncAPI : OAuth2API { 4 -> PlanToWatch 5 -> ReWatching */ - suspend fun score(id: String, status: AbstractSyncStatus): Boolean + suspend fun score(id: String, status: SyncStatus): Boolean - suspend fun getStatus(id: String): AbstractSyncStatus? + suspend fun getStatus(id: String): SyncStatus? suspend fun getResult(id: String): SyncResult? @@ -53,25 +59,14 @@ interface SyncAPI : OAuth2API { override var id: Int? = null, ) : SearchResponse - abstract class AbstractSyncStatus { - abstract var status: SyncWatchType - - /** 1-10 */ - abstract var score: Int? - abstract var watchedEpisodes: Int? - abstract var isFavorite: Boolean? - abstract var maxEpisodes: Int? - } - - data class SyncStatus( - override var status: SyncWatchType, + val status: Int, /** 1-10 */ - override var score: Int?, - override var watchedEpisodes: Int?, - override var isFavorite: Boolean? = null, - override var maxEpisodes: Int? = null, - ) : AbstractSyncStatus() + val score: Int?, + val watchedEpisodes: Int?, + var isFavorite: Boolean? = null, + var maxEpisodes: Int? = null, + ) data class SyncResult( /**Used to verify*/ @@ -125,8 +120,6 @@ 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 } } @@ -161,10 +154,6 @@ 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 9363cb6f..85b877e0 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.AbstractSyncStatus): Resource { + suspend fun score(id: String, status: SyncAPI.SyncStatus): 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 db467639..507c5e2a 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 6112c7db..7d9de43a 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,19 +13,17 @@ 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.Locale +import java.util.* class AniListApi(index: Int) : AccountManager(index), SyncAPI { override var name = "AniList" @@ -33,7 +31,6 @@ 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 @@ -64,7 +61,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun handleRedirect(url: String): Boolean { val sanitizer = - splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR + splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR val token = sanitizer["access_token"]!! val expiresIn = sanitizer["expires_in"]!! @@ -88,7 +85,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, @@ -102,7 +99,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(), @@ -161,23 +158,23 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { val internalId = id.toIntOrNull() ?: return null val data = getDataAboutId(internalId) ?: return null return SyncAPI.SyncStatus( score = data.score, watchedEpisodes = data.progress, - status = SyncWatchType.fromInternalId(data.type?.value ?: return null), + status = data.type?.value ?: return null, isFavorite = data.isFavourite, maxEpisodes = data.episodes, ) } - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { return postDataAboutId( id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status.internalId), + fromIntToAnimeStatus(status.status), status.score, status.watchedEpisodes ).also { @@ -302,12 +299,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)) } @@ -497,7 +494,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, @@ -537,7 +534,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { headers = mapOf( "Authorization" to "Bearer " + (getAuth() ?: return@suspendSafeApiCall null), - if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" + if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache" ), cacheTime = 0, data = mapOf( @@ -598,7 +595,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?, @@ -632,9 +629,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ?: this.media.coverImage.medium, null, null, - this.media.seasonYear.toYear(), - null, - plot = this.media.description, + null ) } } @@ -649,7 +644,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? { @@ -661,7 +656,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) } @@ -680,7 +675,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { // To fill empty lists when AniList does not return them val baseMap = - AniListStatusType.entries.filter { it.value >= 0 }.associate { + AniListStatusType.values().filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -691,8 +686,6 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, - ListSorting.ReleaseDateNew, - ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) @@ -766,11 +759,6 @@ 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, @@ -778,43 +766,19 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { progress: Int? ): Boolean { val q = - // 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 - } + """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 != "" } @@ -840,7 +804,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, @@ -862,8 +826,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) @@ -882,7 +846,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class SeasonData( - @JsonProperty("Media") val media: SeasonMedia, + @JsonProperty("Media") val Media: SeasonMedia, ) data class SeasonMedia( @@ -1054,7 +1018,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class AniListData( - @JsonProperty("Viewer") val viewer: AniListViewer?, + @JsonProperty("Viewer") val Viewer: AniListViewer?, ) data class AniListRoot( @@ -1094,7 +1058,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class LikeData( - @JsonProperty("Viewer") val viewer: LikeViewer?, + @JsonProperty("Viewer") val Viewer: LikeViewer?, ) data class LikeRoot( @@ -1134,7 +1098,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetDataData( - @JsonProperty("Media") val media: GetDataMedia?, + @JsonProperty("Media") val Media: GetDataMedia?, ) data class GetDataRoot( @@ -1167,7 +1131,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 94537ea3..7ec168da 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,7 +11,6 @@ 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 new file mode 100644 index 00000000..668d10bd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt @@ -0,0 +1,265 @@ +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 0d9a4d13..0b081220 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,11 +8,7 @@ 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 @@ -21,7 +17,6 @@ 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 @@ -49,11 +44,11 @@ class LocalList : SyncAPI { override val mainUrl = "" override val syncIdName = SyncIdName.LocalList - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { return true } - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { return null } @@ -73,57 +68,28 @@ class LocalList : SyncAPI { }?.distinctBy { it.first } ?: return null val list = ioWork { - 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 -> + 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( - list.map { SyncAPI.LibraryList(txt(it.key), it.value) }, + (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, - ListSorting.UpdatedNew, - ListSorting.UpdatedOld, - ListSorting.ReleaseDateNew, - ListSorting.ReleaseDateOld, +// ListSorting.UpdatedNew, +// ListSorting.UpdatedOld, // 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 08c18653..5164b606 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,22 +16,16 @@ 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.time.Instant -import java.time.format.DateTimeFormatter -import java.util.Calendar -import java.util.Date -import java.util.Locale -import java.util.TimeZone +import java.util.* /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */ const val MAL_MAX_SEARCH_LIMIT = 25 @@ -45,7 +39,6 @@ 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" @@ -56,6 +49,7 @@ 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, @@ -88,7 +82,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { this.name, node.id.toString(), "$mainUrl/anime/${node.id}/", - node.mainPicture?.large ?: node.mainPicture?.medium + node.main_picture?.large ?: node.main_picture?.medium ) } } @@ -97,10 +91,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() } - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { return setScoreRequest( id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status.internalId), + fromIntToAnimeStatus(status.status), status.score, status.watchedEpisodes ).also { @@ -182,7 +176,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDate(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time + SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time } catch (e: Exception) { null } @@ -194,7 +188,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { apiName = this.name, syncId = node.id.toString(), url = "$mainUrl/anime/${node.id}", - posterUrl = node.mainPicture?.large + posterUrl = node.main_picture?.large ) } @@ -248,12 +242,12 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { val internalId = id.toIntOrNull() ?: return null val data = - getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status") + getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status") return SyncAPI.SyncStatus( score = data?.score, - status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), + status = malStatusAsString.indexOf(data?.status), isFavorite = null, - watchedEpisodes = data?.numEpisodesWatched, + watchedEpisodes = data?.num_episodes_watched, ) } @@ -295,7 +289,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDateLong(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { @@ -306,7 +300,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun handleRedirect(url: String): Boolean { val sanitizer = - splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR + splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR val state = sanitizer["state"]!! if (state == "RequestID$requestId") { val currentCode = sanitizer["code"]!! @@ -355,9 +349,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { try { if (response != "") { val token = parseJson(response) - setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime)) - setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken) - setKey(accountId, MAL_TOKEN_KEY, token.accessToken) + 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) requireLibraryRefresh = true } } catch (e: Exception) { @@ -399,62 +393,55 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class Node( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String, - @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("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("status") val status: String?, - @JsonProperty("start_date") val startDate: String?, - @JsonProperty("end_date") val endDate: String?, - @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, + @JsonProperty("start_date") val start_date: String?, + @JsonProperty("end_date") val end_date: String?, + @JsonProperty("average_episode_duration") val average_episode_duration: 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 numListUsers: Int?, - @JsonProperty("num_favorites") val numFavorites: Int?, - @JsonProperty("num_scoring_users") val numScoringUsers: Int?, - @JsonProperty("start_season") val startSeason: StartSeason?, + @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("broadcast") val broadcast: Broadcast?, @JsonProperty("nsfw") val nsfw: String?, - @JsonProperty("created_at") val createdAt: String?, - @JsonProperty("updated_at") val updatedAt: String? + @JsonProperty("created_at") val created_at: String?, + @JsonProperty("updated_at") val updated_at: String? ) data class ListStatus( @JsonProperty("status") val status: String?, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, - @JsonProperty("is_rewatching") val isRewatching: Boolean, - @JsonProperty("updated_at") val updatedAt: String, + @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, + @JsonProperty("is_rewatching") val is_rewatching: Boolean, + @JsonProperty("updated_at") val updated_at: String, ) data class Data( @JsonProperty("node") val node: Node, - @JsonProperty("list_status") val listStatus: ListStatus?, + @JsonProperty("list_status") val list_status: ListStatus?, ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.node.title, "https://myanimelist.net/anime/${this.node.id}/", this.node.id.toString(), - this.listStatus?.numEpisodesWatched, - this.node.numEpisodes, - this.listStatus?.score?.times(10), - parseDateLong(this.listStatus?.updatedAt), + this.list_status?.num_episodes_watched, + this.node.num_episodes, + this.list_status?.score?.times(10), + parseDateLong(this.list_status?.updated_at), "MAL", TvType.Anime, - this.node.mainPicture?.large ?: this.node.mainPicture?.medium, + this.node.main_picture?.large ?: this.node.main_picture?.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} ) } } @@ -480,8 +467,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ) data class Broadcast( - @JsonProperty("day_of_the_week") val dayOfTheWeek: String?, - @JsonProperty("start_time") val startTime: String? + @JsonProperty("day_of_the_week") val day_of_the_week: String?, + @JsonProperty("start_time") val start_time: String? ) private fun getMalAnimeListCached(): Array? { @@ -501,14 +488,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { val list = getMalAnimeListSmart()?.groupBy { - convertToStatus(it.listStatus?.status ?: "").stringRes + convertToStatus(it.list_status?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } } ?: emptyMap() // To fill empty lists when MAL does not return them val baseMap = - MalStatusType.entries.filter { it.value >= 0 }.associate { + MalStatusType.values().filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -519,8 +506,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, - ListSorting.ReleaseDateNew, - ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) @@ -585,7 +570,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ).text val values = parseJson(res) val titles = - values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) } + values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) } for (t in titles) { allTitles[t.id] = t } @@ -594,13 +579,11 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? { + 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", Locale.getDefault()).parse(it) - ?.before(Date.from(Instant.now())) != false - ) return@convertJapanTimeToTimeRemaining null + if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null } } catch (e: ParseException) { logError(e) @@ -617,7 +600,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", Locale.getDefault()) + val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm") dateFormat.timeZone = TimeZone.getTimeZone("Japan") val parsedDate = dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null @@ -661,13 +644,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { id: Int, status: MalStatusType? = null, score: Int? = null, - numWatchedEpisodes: Int? = null, + num_watched_episodes: Int? = null, ): Boolean { val res = setScoreRequest( id, if (status == null) null else malStatusAsString[maxOf(0, status.value)], score, - numWatchedEpisodes + num_watched_episodes ) return if (res.isNullOrBlank()) { @@ -684,18 +667,17 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( id: Int, status: String? = null, score: Int? = null, - numWatchedEpisodes: Int? = null, + num_watched_episodes: Int? = null, ): String? { val data = mapOf( "status" to status, "score" to score?.toString(), - "num_watched_episodes" to numWatchedEpisodes?.toString() - ).filterValues { it != null } as Map + "num_watched_episodes" to num_watched_episodes?.toString() + ).filter { it.value != null } as Map return app.put( "$apiUrl/v2/anime/$id/my_list_status", @@ -708,10 +690,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class ResponseToken( - @JsonProperty("token_type") val tokenType: String, - @JsonProperty("expires_in") val expiresIn: Int, - @JsonProperty("access_token") val accessToken: String, - @JsonProperty("refresh_token") val refreshToken: String, + @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, ) data class MalRoot( @@ -720,7 +702,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalDatum( @JsonProperty("node") val node: MalNode, - @JsonProperty("list_status") val listStatus: MalStatus, + @JsonProperty("list_status") val list_status: MalStatus, ) data class MalNode( @@ -737,16 +719,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 numEpisodesWatched: Int, - @JsonProperty("is_rewatching") val isRewatching: Boolean, - @JsonProperty("updated_at") val updatedAt: String, + @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, + @JsonProperty("is_rewatching") val is_rewatching: Boolean, + @JsonProperty("updated_at") val updated_at: String, ) data class MalUser( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("location") val location: String, - @JsonProperty("joined_at") val joinedAt: String, + @JsonProperty("joined_at") val joined_at: String, @JsonProperty("picture") val picture: String?, ) @@ -759,9 +741,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 numEpisodes: Int, - @JsonProperty("my_list_status") val myListStatus: MalStatus?, - @JsonProperty("main_picture") val mainPicture: MalMainPicture?, + @JsonProperty("num_episodes") val num_episodes: Int, + @JsonProperty("my_list_status") val my_list_status: MalStatus?, + @JsonProperty("main_picture") val main_picture: 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 37b95614..3e372c2d 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,13 +2,12 @@ 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 @@ -16,8 +15,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 okhttp3.Interceptor -import okhttp3.Response +import java.net.URLEncoder +import java.nio.charset.StandardCharsets class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { override val idPrefix = "opensubtitles" @@ -29,31 +28,14 @@ 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 API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" - const val HOST = "https://api.opensubtitles.com/api/v1" + const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" + const val host = "https://api.opensubtitles.com/api/v1" const val TAG = "OPENSUBS" - const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms + const val coolDownDuration: 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 } @@ -65,7 +47,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMs + COOLDOWN_DURATION + currentCoolDown = unixTimeMs + coolDownDuration throw ErrorLoadingException("Too many requests") } @@ -114,15 +96,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( - "Content-Type" to "application/json", + "Api-Key" to apiKey, + "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}") @@ -133,7 +115,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi SubtitleOAuthEntity( user = username, pass = password, - accessToken = token.token ?: run { + access_token = token.token ?: run { return false }) ) @@ -167,13 +149,11 @@ 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 } @@ -185,7 +165,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi throwIfCantDoRequest() val fixedLang = fixLanguage(query.lang) - val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 + val imdbId = query.imdb ?: 0 val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 @@ -196,16 +176,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) { @@ -227,12 +207,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.hearingImpaired ?: false + val isHearingImpaired = attr.hearing_impaired ?: false //Log.i(TAG, "Result id/name => ${item.id} / $name") item.attributes?.files?.forEach { file -> val resultData = file.fileId?.toString() ?: "" @@ -265,19 +245,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?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${currentSession?.access_token ?: 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}") @@ -298,7 +278,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi data class SubtitleOAuthEntity( var user: String, var pass: String, - var accessToken: String, + var access_token: String, ) data class OAuthToken( @@ -323,7 +303,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 hearingImpaired: Boolean? = null, + @JsonProperty("hearing_impaired") var hearing_impaired: 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 deleted file mode 100644 index 50517f9d..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ /dev/null @@ -1,1112 +0,0 @@ -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 deleted file mode 100644 index 8dad1f88..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index 29544e65..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt +++ /dev/null @@ -1,247 +0,0 @@ -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 9150cfc5..4ab2e8e2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -1,24 +1,16 @@ 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 @@ -50,7 +42,7 @@ class APIRepository(val api: MainAPI) { private val cache = threadSafeListOf() private var cacheIndex: Int = 0 - const val CACHE_SIZE = 20 + const val cacheSize = 20 } private fun afterPluginsLoaded(forceReload: Boolean) { @@ -94,9 +86,9 @@ class APIRepository(val api: MainAPI) { val add = SavedLoadResponse(unixTime, response, lookingForHash) synchronized(cache) { - if (cache.size > CACHE_SIZE) { + if (cache.size > cacheSize) { cache[cacheIndex] = add // rolling cache - cacheIndex = (cacheIndex + 1) % CACHE_SIZE + cacheIndex = (cacheIndex + 1) % cacheSize } else { cache.add(add) } @@ -182,7 +174,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/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt similarity index 68% rename from app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt rename to app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt index 78ad2a6b..b4c07792 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt @@ -3,13 +3,12 @@ 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, @@ -25,7 +24,7 @@ class GrdLayoutManager(val context: Context, spanCount: Int) : } } - /*override fun onRequestChildFocus( + override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, @@ -33,17 +32,13 @@ class GrdLayoutManager(val context: Context, spanCount: Int) : ): Boolean { // android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams return try { - if(focused != null) { - // val pos = maxOf(0, getPosition(focused) - 2) // IDK WHY - val pos = getPosition(focused) - if(pos >= 0) parent.scrollToPosition(pos) - } - + val pos = maxOf(0, getPosition(focused!!) - 2) + 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? { @@ -70,47 +65,32 @@ class GrdLayoutManager(val context: Context, spanCount: Int) : val spanCount = this.spanCount val orientation = this.orientation - // 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) { + when (direction) { 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 (correctDirection) { + when (direction) { View.FOCUS_DOWN -> { return 1 } - View.FOCUS_UP -> { return -1 } - View.FOCUS_RIGHT -> { return spanCount } - View.FOCUS_LEFT -> { return -spanCount } @@ -162,32 +142,4 @@ 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/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt deleted file mode 100644 index e930961c..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ /dev/null @@ -1,252 +0,0 @@ -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 1eaac505..46ddce09 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -6,10 +6,9 @@ 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 @@ -24,13 +23,12 @@ 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 @@ -99,7 +97,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 { @@ -264,7 +262,6 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi var isLoadingMore = false - override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() @@ -297,8 +294,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val generator = RepoLinkGenerator(listOf(epData)) val isSuccessful = safeApiCall { - generator.generateLinks( - clearCache = false, type = LoadType.Chromecast, + generator.generateLinks(clearCache = false, isCasting = true, callback = { it.first?.let { link -> currentLinks.add(link) 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 4879d2e0..556ebd34 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt @@ -16,16 +16,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.AppCompatImageView import androidx.core.view.isVisible import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.ActivityEasterEggMonkeBinding +import kotlinx.android.synthetic.main.activity_easter_egg_monke.* +import java.util.* class EasterEggMonke : AppCompatActivity() { - lateinit var binding : ActivityEasterEggMonkeBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - binding = ActivityEasterEggMonkeBinding.inflate(layoutInflater) - setContentView(binding.root) + setContentView(R.layout.activity_easter_egg_monke) val handler = Handler(mainLooper) lateinit var runnable: Runnable @@ -34,14 +32,15 @@ class EasterEggMonke : AppCompatActivity() { handler.postDelayed(runnable, 300) } handler.postDelayed(runnable, 1000) + } private fun shower() { - 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 containerW = frame.width + val containerH = frame.height + var starW: Float = monke.width.toFloat() + var starH: Float = monke.height.toFloat() val newStar = AppCompatImageView(this) val idx = (monkeys.size * Math.random()).toInt() @@ -49,9 +48,9 @@ class EasterEggMonke : AppCompatActivity() { newStar.isVisible = true newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT) - binding.frame.addView(newStar) + frame.addView(newStar) - newStar.scaleX += Math.random().toFloat() * 1.5f + newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX newStar.scaleY = newStar.scaleX starW *= newStar.scaleX starH *= newStar.scaleY @@ -71,7 +70,7 @@ class EasterEggMonke : AppCompatActivity() { set.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - binding.frame.removeView(newStar) + 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 deleted file mode 100644 index 12a5ae2a..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt +++ /dev/null @@ -1,39 +0,0 @@ -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 b778ba5a..eb4eb666 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -13,29 +13,6 @@ 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?) = entries.find { value -> value.internalId == id } ?: NONE + fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE } -} - -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 - } -} +} \ No newline at end of file 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 5e2b97e5..19e24f74 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -8,30 +8,24 @@ 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.AppContextUtils.loadRepository - +import com.lagradost.cloudstream3.utils.AppUtils.loadRepository +import kotlinx.android.synthetic.main.fragment_webview.* 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() } - binding?.webView?.webViewClient = object : WebViewClient() { - @OptIn(UnstableApi::class) + web_view.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -46,28 +40,24 @@ class WebviewFragment : Fragment() { return super.shouldOverrideUrlLoading(view, request) } } - binding?.webView?.apply { - WebViewResolver.webViewUserAgent = settings.userAgentString - addJavascriptInterface(RepoApi(activity), "RepoApi") - settings.javaScriptEnabled = true - settings.userAgentString = USER_AGENT - settings.domStorageEnabled = true + 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 // WebView.setWebContentsDebuggingEnabled(true) - loadUrl(url) - } - + web_view.loadUrl(url) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val localBinding = FragmentWebviewBinding.inflate(inflater, container, false) - binding = localBinding + ): View? { // Inflate the layout for this fragment - return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false) + return inflater.inflate(R.layout.fragment_webview, container, false) } companion object { @@ -80,7 +70,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 deleted file mode 100644 index de0b5c05..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt +++ /dev/null @@ -1,200 +0,0 @@ -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 deleted file mode 100644 index d2aca862..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ /dev/null @@ -1,356 +0,0 @@ -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 deleted file mode 100644 index 0da69f9c..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index eb907b34..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 14559607..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index d211cb87..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ /dev/null @@ -1,414 +0,0 @@ -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 494e82e5..add36f1a 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,30 +1,27 @@ package com.lagradost.cloudstream3.ui.download +import android.app.Activity import android.content.DialogInterface -import android.net.Uri +import android.widget.Toast import androidx.appcompat.app.AlertDialog -import com.google.android.material.snackbar.Snackbar import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys -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.player.DownloadFileGenerator -import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -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.AppUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar +import com.lagradost.cloudstream3.utils.ExtractorUri 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(click: DownloadClickEvent) { + fun handleDownloadClick(activity: Activity?, click: DownloadClickEvent) { val id = click.data.id + if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return when (click.action) { DOWNLOAD_ACTION_DELETE_FILE -> { activity?.let { ctx -> @@ -33,15 +30,9 @@ object DownloadButtonSetup { DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { - VideoDownloadManager.deleteFilesAndUpdateSettings( - ctx, - setOf(id), - MainScope() - ) + VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id) } - DialogInterface.BUTTON_NEGATIVE -> { - // Do nothing on cancel } } } @@ -66,13 +57,11 @@ 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) { @@ -91,7 +80,6 @@ object DownloadButtonSetup { } } } - DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = @@ -101,80 +89,64 @@ object DownloadButtonSetup { )?.fileLength ?: 0 if (length > 0) { - showSnackbar( - act, - R.string.offline_file, - Snackbar.LENGTH_LONG - ) + showToast(act, R.string.delete, Toast.LENGTH_LONG) + } else { + showToast(act, R.string.download, Toast.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 - val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) - ?.mapNotNull { - getKey(it) - } - ?.filter { it.parentId == click.data.parentId } - - val currentSeason = click.data.season ?: 0 - val currentEpisode = click.data.episode - - 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, - ) - ) - } - act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator(items) + DownloadFileGenerator( + listOf( + ExtractorUri( + uri = info.path, + + 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, + + 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 + //) ) } } 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 new file mode 100644 index 00000000..0096ff42 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonViewHolder.kt @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..a541171b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt @@ -0,0 +1,153 @@ +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 09c48a04..477a18e0 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,35 +1,27 @@ 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.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView 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.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.Coroutines.main +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV +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 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) @@ -38,170 +30,77 @@ class DownloadChildFragment : Fragment() { } override fun onDestroyView() { - detachBackPressedCallback() - binding = null + (download_child_list?.adapter as DownloadChildAdapter?)?.killAdapter() + downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } super.onDestroyView() } - 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 + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_child_downloads, container, false) } + 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?.onBackPressedDispatcher?.onBackPressed() + activity?.onBackPressed() // TODO FIX return } + context?.fixPaddingStatusbar(download_child_root) - binding?.downloadChildToolbar?.apply { - title = name - if (isLayout(PHONE or EMULATOR)) { - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() + 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) } } - setAppBarNoScrollFlagsOnTV() } - binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() + downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } - observe(downloadsViewModel.childCards) { - if (it.isEmpty()) { - activity?.onBackPressedDispatcher?.onBackPressed() - return@observe - } + download_child_list.adapter = adapter + download_child_list.layoutManager = GridLayoutManager(context, 1) - (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) + updateList(folder) } } \ 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 447b4f13..f0340845 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,62 +1,53 @@ 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.activity.result.contract.ActivityResultContracts -import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity 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.databinding.FragmentDownloadsBinding -import com.lagradost.cloudstream3.databinding.StreamInputBinding -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.isMovieType 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.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.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.utils.AppUtils.loadResult +import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore 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.UIHelper.setAppBarNoScrollFlagsOnTV +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 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( @@ -67,9 +58,19 @@ 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() { - detachBackPressedCallback() - binding = null + if (downloadDeleteEventListener != null) { + VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!! + downloadDeleteEventListener = null + } + (download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter() super.onDestroyView() } @@ -77,315 +78,181 @@ class DownloadFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] - val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root + ): View? { + downloadsViewModel = + ViewModelProvider(this)[DownloadViewModel::class.java] + + return inflater.inflate(R.layout.fragment_downloads, container, false) } + private var downloadDeleteEventListener: ((Int) -> Unit)? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) hideKeyboard() - binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() - binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - /** - * 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) + observe(downloadsViewModel.noDownloadsText) { + text_no_downloads.text = it } - - /** - * 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) { - (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it) - binding?.downloadLoading?.isVisible = false - binding?.textNoDownloads?.isVisible = it.isEmpty() + setList(it) + download_loading.isVisible = false } observe(downloadsViewModel.availableBytes) { - updateStorageInfo( - view.context, - it, - R.string.free_storage, - binding?.downloadFreeTxt, - binding?.downloadFree - ) + download_free_txt?.text = + getString(R.string.storage_size_format).format( + getString(R.string.free_storage), + formatShortFileSize(view.context, it) + ) + download_free?.setLayoutWidth(it) } observe(downloadsViewModel.usedBytes) { - updateStorageInfo( - view.context, - it, - R.string.used_storage, - binding?.downloadUsedTxt, - binding?.downloadUsed - ) - - // 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 + 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) { - 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) - - 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 -> 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) - } - ) - - 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() } - } - downloadStreamButton.apply { - isGone = isLayout(TV) - setOnClickListener { showStreamInputDialog(it.context) } - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - handleScroll(scrollY - oldScrollY) - } - } - - 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.action_navigation_downloads_to_navigation_download_child, - DownloadChildFragment.newInstance(click.data.name, folder) - ) - } - } - - 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) + 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) + } + } + } ) - } - } - 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) + 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) + } + } + } } - (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) - } + downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } - 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 + download_list?.adapter = adapter + download_list?.layoutManager = GridLayoutManager(context, 1) + + // 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 + } + + fun activateSwitchOnHls(text: String?) { + dialog.hls_switch?.isChecked = normalSafeApiCall { + URI(text).path?.substringAfterLast(".")?.contains("m3u") + } == true + } + + dialog.stream_referer?.doOnTextChanged { text, _, _, _ -> + if (!preventAutoSwitching) + activateSwitchOnHls(text?.toString()) + } + + (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) + } + + 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() + + activity?.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + LinkGenerator( + listOf(url), + extract = true, + referer = referer, + isM3u8 = dialog.hls_switch?.isChecked + ) ) ) - ) + + dialog.dismissSafe(activity) + } + } + + dialog.cancel_btt?.setOnClickListener { dialog.dismissSafe(activity) } } - - binding.cancelBtt.setOnClickListener { - 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()) - 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) + context?.fixPaddingStatusbar(download_root) } } \ 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 new file mode 100644 index 00000000..29bb303a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt @@ -0,0 +1,180 @@ +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 137f1355..3a74a715 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,439 +1,122 @@ 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.R -import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isMovieType 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.deleteFilesAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager 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>() - val headerCards: LiveData> = _headerCards - - private val _childCards = MutableLiveData>() - val childCards: LiveData> = _childCards + private val _headerCards = + MutableLiveData>().apply { listOf() } + val headerCards: LiveData> = _headerCards private val _usedBytes = MutableLiveData() - val usedBytes: LiveData = _usedBytes - private val _availableBytes = MutableLiveData() - val availableBytes: LiveData = _availableBytes - private val _downloadBytes = MutableLiveData() + + val usedBytes: LiveData = _usedBytes + val availableBytes: LiveData = _availableBytes val downloadBytes: LiveData = _downloadBytes - private val _selectedBytes = MutableLiveData(0) - val selectedBytes: LiveData = _selectedBytes - - private val _isMultiDeleteState = MutableLiveData(false) - val isMultiDeleteState: LiveData = _isMultiDeleteState - - private val _selectedItemIds = MutableLiveData>(mutableSetOf()) - val selectedItemIds: LiveData> = _selectedItemIds - - private var previousVisual: List? = null - - fun setIsMultiDeleteState(value: Boolean) { - _isMultiDeleteState.postValue(value) - } - - 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) - } - - 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) { - val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) - .mapNotNull { context.getKey(it) } + 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 - - 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() + val totalBytesUsedByChild = HashMap() // parentId : bytes - val currentBytesUsedByChild = mutableMapOf() + val currentBytesUsedByChild = HashMap() // parentId : downloadsCount - val totalDownloads = mutableMapOf() + val totalDownloads = HashMap() - 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 + // Gets all children downloads + withContext(Dispatchers.IO) { + for (c in children) { + val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue - totalBytesUsedByChild.merge(child.parentId, len, Long::plus) - currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) - totalDownloads.merge(child.parentId, 1, Int::plus) + if (childFile.fileLength <= 1) continue + val len = childFile.totalBytes + val flen = childFile.fileLength + + 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 + } } - 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, + val cached = withContext(Dispatchers.IO) { // wont fetch useless keys + totalDownloads.entries.filter { it.value > 0 }.mapNotNull { + context.getKey( + DOWNLOAD_HEADER_CACHE, + it.key.toString() ) } - }.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) { + 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 + } try { val stat = StatFs(Environment.getExternalStorageDirectory().path) - val localBytesAvailable = stat.availableBytes + + val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong val localTotalBytes = stat.blockSizeLong * stat.blockCountLong val localDownloadedBytes = visual.sumOf { it.totalBytes } - val localUsedBytes = localTotalBytes - localBytesAvailable - _usedBytes.postValue(localUsedBytes) + + _usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes) _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 new file mode 100644 index 00000000..77878432 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt @@ -0,0 +1,264 @@ +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 deleted file mode 100644 index 908e3a80..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ /dev/null @@ -1,218 +0,0 @@ -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 deleted file mode 100644 index 20a44461..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 29c2daa2..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ /dev/null @@ -1,339 +0,0 @@ -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 deleted file mode 100644 index 11818a7e..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/ProgressBarAnimation.kt +++ /dev/null @@ -1,18 +0,0 @@ -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 b25486eb..b90a4e43 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,142 +1,146 @@ package com.lagradost.cloudstream3.ui.home import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.viewbinding.ViewBinding +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView 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.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.IsBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.toPx - -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 - }*/ - - // 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() - } - } - } -} +import kotlinx.android.synthetic.main.home_result_grid.view.background_card +import kotlinx.android.synthetic.main.home_result_grid_expanded.view.* class HomeChildItemAdapter( - fragment: Fragment, - id: Int, + val cardList: MutableList, + private val overrideLayout: Int? = null, private val nextFocusUp: Int? = null, private val nextFocusDown: Int? = null, private val clickCallback: (SearchClickCallback) -> Unit, ) : - BaseAdapter(fragment, id) { + RecyclerView.Adapter() { var isHorizontal: Boolean = false var hasNext: Boolean = false - 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 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 - val root = LayoutInflater.from(parent.context).inflate(layout, parent, false) - val binding = HomeResultGridBinding.bind(root)*/ - - val inflater = LayoutInflater.from(parent.context) - val binding = if (expanded) HomeResultGridExpandedBinding.inflate( - inflater, - parent, - false - ) else HomeResultGridBinding.inflate(inflater, parent, false) - return HomeScrollViewHolderState(binding) + return CardViewHolder( + LayoutInflater.from(parent.context).inflate(layout, parent, false), + clickCallback, + itemCount, + nextFocusUp, + nextFocusDown, + isHorizontal + ) } - 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 - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.itemCount = itemCount // i know ugly af + holder.bind(cardList[position], position) } } + } - 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 + 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) ) - holder.itemView.tag = position + 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 + } + + (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 +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].name == newList[newItemPosition].name + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + 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 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 49de2503..5cf6fc8e 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,6 +7,7 @@ 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 @@ -17,49 +18,76 @@ 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.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.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.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.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback -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.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.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() @@ -97,26 +125,22 @@ 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) - val binding: HomeEpisodesExpandedBinding = HomeEpisodesExpandedBinding.inflate( - bottomSheetDialogBuilder.layoutInflater, - null, - false - ) - bottomSheetDialogBuilder.setContentView(binding.root) - //val title = bottomSheetDialogBuilder.findViewById(R.id.home_expanded_text)!! + + bottomSheetDialogBuilder.setContentView(R.layout.home_episodes_expanded) + val title = bottomSheetDialogBuilder.findViewById(R.id.home_expanded_text)!! //title.findViewTreeLifecycleOwner().lifecycle.addObserver() val item = expand.list - binding.homeExpandedText.text = item.name - // val recycle = - // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_recycler)!! - //val titleHolder = - // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_drag_down)!! + title.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 { @@ -135,10 +159,10 @@ class HomeFragment : Fragment() { // }) //} // } - //val delete = bottomSheetDialogBuilder.home_expanded_delete - binding.homeExpandedDelete.isGone = deleteCallback == null + val delete = bottomSheetDialogBuilder.home_expanded_delete + delete.isGone = deleteCallback == null if (deleteCallback != null) { - binding.homeExpandedDelete.setOnClickListener { + delete.setOnClickListener { try { val builder: AlertDialog.Builder = AlertDialog.Builder(context) val dialogClickListener = @@ -148,7 +172,6 @@ class HomeFragment : Fragment() { deleteCallback.invoke() bottomSheetDialogBuilder.dismissSafe(this) } - DialogInterface.BUTTON_NEGATIVE -> {} } } @@ -168,27 +191,26 @@ class HomeFragment : Fragment() { } } } - binding.homeExpandedDragDown.setOnClickListener { + + titleHolder.setOnClickListener { bottomSheetDialogBuilder.dismissSafe(this) } // Span settings - binding.homeExpandedRecycler.spanCount = currentSpan + recycle.spanCount = currentSpan - 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 + 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) } + }.apply { + hasNext = expand.hasNext + } - binding.homeExpandedRecycler.addOnScrollListener(object : - RecyclerView.OnScrollListener() { + recycle.addOnScrollListener(object : RecyclerView.OnScrollListener() { var expandCount = 0 val name = expand.list.name @@ -216,7 +238,7 @@ class HomeFragment : Fragment() { }) val spanListener = { span: Int -> - binding.homeExpandedRecycler.spanCount = span + recycle.spanCount = span //(recycle.adapter as SearchAdapter).notifyDataSetChanged() } @@ -233,7 +255,7 @@ class HomeFragment : Fragment() { return bottomSheetDialogBuilder } - private fun getPairList( + fun getPairList( anime: Chip?, cartoons: Chip?, tvs: Chip?, @@ -258,19 +280,19 @@ class HomeFragment : Fragment() { ) } - private fun getPairList(header: TvtypesChipsBinding) = getPairList( - header.homeSelectAnime, - header.homeSelectCartoons, - header.homeSelectTvSeries, - header.homeSelectDocumentaries, - header.homeSelectMovies, - header.homeSelectAsian, - header.homeSelectLivestreams, - header.homeSelectNsfw, - header.homeSelectOthers + 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 ) - fun validateChips(header: TvtypesChipsBinding?, validTypes: List) { + fun validateChips(header: ChipGroup?, validTypes: List) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { @@ -279,7 +301,7 @@ class HomeFragment : Fragment() { } } - fun updateChips(header: TvtypesChipsBinding?, selectedTypes: List) { + fun updateChips(header: ChipGroup?, selectedTypes: List) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { @@ -289,21 +311,10 @@ class HomeFragment : Fragment() { } fun bindChips( - header: TvtypesChipsBinding?, + header: ChipGroup?, 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) @@ -311,17 +322,6 @@ 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,13 +344,7 @@ class HomeFragment : Fragment() { BottomSheetDialog(this) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - val binding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( - builder.layoutInflater, - null, - false - ) - - builder.setContentView(binding.root) + builder.setContentView(R.layout.home_select_mainpage) builder.show() builder.let { dialog -> val isMultiLang = getApiProviderLangSettings().let { set -> @@ -361,13 +355,19 @@ class HomeFragment : Fragment() { var currentApiName = selectedApiName var currentValidApis: MutableList = mutableListOf() - val preSelectedTypes = DataStoreHelper.homePreference.toMutableList() + val preSelectedTypes = this.getKey>(HOME_PREF_HOMEPAGE) + ?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } + ?.toMutableList() + ?: mutableListOf(TvType.Movie, TvType.TvSeries) - binding.cancelBtt.setOnClickListener { + val cancelBtt = dialog.findViewById(R.id.cancel_btt) + val applyBtt = dialog.findViewById(R.id.apply_btt) + + cancelBtt?.setOnClickListener { dialog.dismissSafe() } - binding.applyBtt.setOnClickListener { + applyBtt?.setOnClickListener { if (currentApiName != selectedApiName) { currentApiName?.let(callback) } @@ -389,7 +389,7 @@ class HomeFragment : Fragment() { } fun updateList() { - DataStoreHelper.homePreference = preSelectedTypes + this.setKey(HOME_PREF_HOMEPAGE, preSelectedTypes) arrayAdapter.clear() currentValidApis = validAPIs.filter { api -> @@ -408,7 +408,7 @@ class HomeFragment : Fragment() { } bindChips( - binding.tvtypesChipsScroll.tvtypesChips, + dialog.home_select_group, preSelectedTypes, validAPIs.flatMap { it.supportedTypes }.distinct() ) { list -> @@ -423,9 +423,6 @@ class HomeFragment : Fragment() { private val homeViewModel: HomeViewModel by activityViewModels() - var binding: FragmentHomeBinding? = null - - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -433,26 +430,14 @@ class HomeFragment : Fragment() { ): View? { //homeViewModel = // ViewModelProvider(this).get(HomeViewModel::class.java) - bottomSheetDialog?.ownShow() val layout = - 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 + if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home + return inflater.inflate(layout, container, false) } override fun onDestroyView() { - bottomSheetDialog?.ownHide() - binding = null super.onDestroyView() } @@ -465,7 +450,7 @@ class HomeFragment : Fragment() { private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> - homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) + homeViewModel.loadAndCancel(api) } /*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf() @@ -482,148 +467,196 @@ 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() - binding?.apply { - //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) - //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) - homeApiFab.setOnClickListener(apiChangeClickListener) - homeChangeApi.setOnClickListener(apiChangeClickListener) - homeSwitchAccount.setOnClickListener { - activity?.showAccountSelectLinear() + home_change_api_loading?.setOnClickListener(apiChangeClickListener) + home_api_fab?.setOnClickListener(apiChangeClickListener) + home_random?.setOnClickListener { + if (listHomepageItems.isNotEmpty()) { + activity.loadSearchResult(listHomepageItems.random()) } - - 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 - ) && isLayout(PHONE) - binding?.homeRandom?.visibility = View.GONE + 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 + ) } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - binding?.homeApiFab?.text = apiName - binding?.homeChangeApi?.text = apiName + home_api_fab?.text = apiName + (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setApiName( + apiName + ) } observe(homeViewModel.page) { data -> - binding?.apply { - when (data) { - is Resource.Success -> { - homeLoadingShimmer.stopShimmer() + when (data) { + is Resource.Success -> { + home_loading_shimmer?.stopShimmer() - val d = data.value - val mutableListOfResponse = mutableListOf() - listHomepageItems.clear() + val d = data.value + val mutableListOfResponse = mutableListOf() + listHomepageItems.clear() - (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { - it.copy( - list = it.list.copy(list = it.list.list.toMutableList()) + (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 ) - }.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) + }) { + try { + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(validAPIs[itemId].mainUrl) + startActivity(i) + } catch (e: Exception) { + logError(e) } - 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 } + 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 + } + } + } - 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 - } - is Resource.Loading -> { - (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf()) - homeLoadingShimmer.startShimmer() - homeLoading.isVisible = true - homeLoadingError.isVisible = false - homeMasterRecycler.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 }) } } } @@ -632,37 +665,72 @@ class HomeFragment : Fragment() { //context?.fixPaddingStatusbarView(home_statusbar) //context?.fixPaddingStatusbar(home_padding) + context?.fixPaddingStatusbar(home_loading_statusbar) - observeNullable(homeViewModel.popup) { item -> - if (item == null) { - bottomSheetDialog?.dismissSafe() - bottomSheetDialog = null - return@observeNullable + 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) } - - // 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 8bc0aa28..e6999c9e 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,30 +1,52 @@ 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 androidx.fragment.app.Fragment +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.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding +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 com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R -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.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.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback -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 +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.* class LoadClickCallback( val action: Int = 0, @@ -34,89 +56,174 @@ class LoadClickCallback( ) open class ParentItemAdapter( - open val fragment: Fragment, - id: Int, + private var items: MutableList, private val clickCallback: (SearchClickCallback) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val expandCallback: ((String) -> Unit)? = null, -) : BaseAdapter( - fragment, - id, - diffCallback = BaseDiffCallback( - itemSame = { a, b -> a.list.name == b.list.name }, - contentSame = { a, b -> - a.list.list == b.list.list +) : 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) + } + } }) -) { - 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) + + //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() + } } - 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, + fun bind(expand: HomeViewModel.ExpandableHomepageList) { + val info = expand.list + recyclerView.adapter = HomeChildItemAdapter( + info.list.toMutableList(), clickCallback = clickCallback, - nextFocusUp = homeChildRecyclerview.nextFocusUpId, - nextFocusDown = homeChildRecyclerview.nextFocusDownId, + nextFocusUp = recyclerView.nextFocusUpId, + nextFocusDown = recyclerView.nextFocusDownId, ).apply { isHorizontal = info.isHorizontalImages - hasNext = item.hasNext - submitList(item.list.list) + hasNext = expand.hasNext } - homeChildRecyclerview.setLinearListLayout( - isHorizontal = true, - nextLeft = startFocus, - nextRight = endFocus, - ) - homeChildMoreInfo.text = info.name + recyclerView.setLinearListLayout() + title.text = info.name - homeChildRecyclerview.addOnScrollListener(object : - RecyclerView.OnScrollListener() { + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { var expandCount = 0 - val name = item.list.name + val name = expand.list.name - override fun onScrollStateChanged( - recyclerView: RecyclerView, - newState: Int - ) { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) val adapter = recyclerView.adapter @@ -140,40 +247,27 @@ open class ParentItemAdapter( }) //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() - if (isLayout(PHONE)) { - homeChildMoreInfo.setOnClickListener { - moreInfoClickCallback.invoke(item) + if (!isTvSettings()) { + title.setOnClickListener { + moreInfoClickCallback.invoke(expand) } } } } - - 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()) - } } -@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 +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 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 339ef1e1..94a1a526 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,641 +1,658 @@ 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.ChipGroup +import com.google.android.material.chip.ChipDrawable +import com.lagradost.cloudstream3.APIHolder.getId 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.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.isTvSettings 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.fixPaddingStatusbarMargin +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView -import com.lagradost.cloudstream3.utils.UIHelper.populateChips +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.* class HomeParentItemAdapterPreview( - 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) + 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" - if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) { - binding.homeBookmarkParentItemMoreInfo.isVisible = true + val headItems = 1 - val marginInDp = 50 - val density = binding.horizontalScrollChips.context.resources.displayMetrics.density - val marginInPixels = (marginInDp * density).toInt() + private var availableWatchStatusTypes: Pair, Set> = + setOf() to setOf() - 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 - ), - null - ) - } - - return HeaderViewHolder(binding, viewModel, fragment = fragment) + fun setAvailableWatchStatusTypes(data: Pair, Set>) { + availableWatchStatusTypes = data + holder?.setAvailableWatchStatusTypes(data) } - override fun onBindHeader(holder: ViewHolderState) { - (holder as? HeaderViewHolder)?.bind() + companion object { + private const val VIEW_TYPE_HEADER = 2 + private const val VIEW_TYPE_ITEM = 1 } - private class HeaderViewHolder( - val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment, - ) : - ViewHolderState(binding) { + fun setResumeWatchingData(resumeWatching: List) { + resumeWatchingData = resumeWatching + holder?.updateResume(resumeWatchingData) + } - override fun save(): Bundle = - Bundle().apply { - putParcelable( - "resumeRecyclerView", - resumeRecyclerView.layoutManager?.onSaveInstanceState() - ) - putParcelable( - "bookmarkRecyclerView", - bookmarkRecyclerView.layoutManager?.onSaveInstanceState() - ) - //putInt("previewViewpager", previewViewpager.currentItem) - } + fun setPreviewData(preview: Resource>>) { + previewData = preview + holder?.updatePreview(preview) + } - override fun restore(state: Bundle) { - state.getSafeParcelable("resumeRecyclerView")?.let { recycle -> - resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle) - } - state.getSafeParcelable("bookmarkRecyclerView")?.let { recycle -> - bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle) + 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) } + } - 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 + + 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 ), - 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() + loadCallback, + loadMoreCallback, + changeHomePageCallback, + clickCallback, + reloadStored, + loadStoredData, + searchQueryCallback, + moreInfoClickCallback + ).also { + this.holder = it + } + VIEW_TYPE_ITEM -> super.onCreateViewHolder(parent, viewType) + else -> error("Unhandled viewType=$viewType") + } + } + + override fun getItemCount(): Int { + return super.getItemCount() + headItems + } + + override fun getItemId(position: Int): Long { + if (position == 0) return previewData.hashCode().toLong() + return super.getItemId(position - headItems) + } + + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + when (holder) { + is HeaderViewHolder -> { + holder.onViewDetachedFromWindow() + } + else -> super.onViewDetachedFromWindow(holder) + } + } + + override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { + when (holder) { + is HeaderViewHolder -> { + holder.onViewAttachedToWindow() + } + 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() } } - } - } - } - 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 + 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 ) - ) - } + // itemView.home_preview_title?.text = name - 1 -> { // info - viewModel.click( - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) - } + 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) + ) + } - 2 -> { // remove - DataStoreHelper.setResultWatchState( - callback.card.id, - WatchType.NONE.internalId - ) - viewModel.reloadStored() - } - } - } - */ - } + itemView.home_preview_play_btt?.setOnClickListener { view -> + clickCallback?.invoke( + LoadClickCallback( + START_ACTION_RESUME_LATEST, + view, + position, + this + ) + ) + } - private val previewViewpager: ViewPager2 = - itemView.findViewById(R.id.home_preview_viewpager) + // 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 previewViewpagerText: ViewGroup = - itemView.findViewById(R.id.home_preview_viewpager_text) - // 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_info_btt?.setOnClickListener { view -> + clickCallback?.invoke( + LoadClickCallback(0, view, position, this) + ) + } - 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_next_focus?.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + previewViewpager?.apply { + setCurrentItem(currentItem + 1, true) + } + itemView.home_preview_info_btt?.requestFocus() + } + } - 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( + 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( null, ContextCompat.getDrawable( - homePreviewBookmark.context, - newValue.iconRes + itemView.home_preview_bookmark.context, + watchType.iconRes ), null, null ) - homePreviewBookmark.setText(newValue.stringRes) - } - } - } - } - } + 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) - 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() + ResultViewModel2.updateWatchStatus( + this, + newValue + ) + reloadStored() + } + } } - } - val item = previewAdapter.getItemOrNull(position) ?: return - onSelect(item, position) } } - override fun onViewDetachedFromWindow() { - previewViewpager.unregisterOnPageChangeCallback(previewCallback) + 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) } - 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), + 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 toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder) - - fun bind() = Unit - init { - previewViewpager.setPageTransformer(HomeScrollTransformer()) + itemView.home_preview_change_api?.setOnClickListener { view -> + changeHomePageCallback(view) + } + itemView.home_preview_change_api2?.setOnClickListener { view -> + changeHomePageCallback(view) + } - previewViewpager.adapter = previewAdapter - resumeRecyclerView.adapter = resumeAdapter - bookmarkRecyclerView.adapter = bookmarkAdapter + previewViewpager?.apply { + //if (!isTvSettings()) + setPageTransformer(HomeScrollTransformer()) + //else + // setPageTransformer(null) - resumeRecyclerView.setLinearListLayout( - nextLeft = R.id.nav_rail_view, - nextRight = FOCUS_SELF - ) - bookmarkRecyclerView.setLinearListLayout( - nextLeft = R.id.nav_rail_view, - nextRight = FOCUS_SELF - ) + 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) - fixPaddingStatusbarMargin(topPadding) + 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 for ((chip, watch) in toggleList) { - chip.isChecked = false - chip.setOnCheckedChangeListener { _, isChecked -> + chip?.isChecked = false + chip?.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { - viewModel.loadStoredData(setOf(watch)) + 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() + ) } // Else if all are unchecked -> Do not load data - else if (toggleList.all { !it.first.isChecked }) { - viewModel.loadStoredData(emptySet()) + else if (toggleList.all { it.first?.isChecked != true }) { + loadStoredData(emptySet()) } } } - homeAccount?.setOnClickListener { - activity?.showAccountSelectLinear() - } + itemView.home_search?.context?.fixPaddingStatusbar(itemView.home_search) - 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) - } + 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 } - homePreviewSearchButton.setOnClickListener { _ -> - // Open blank screen. - viewModel.queryTextSubmit("") + override fun onQueryTextChange(newText: String): Boolean { + searchQueryCallback.invoke(true to newText) + //searchViewModel.quickSearch(newText) + return true } - - // 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 - } - }) - } + }) } - private fun updatePreview(preview: Resource>>) { + 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 if (preview is Resource.Success) { - homeNonePadding.apply { + itemView.home_none_padding?.apply { val params = layoutParams params.height = 0 layoutParams = params } } else { - fixPaddingStatusbarView(homeNonePadding) + itemView.home_none_padding?.context?.fixPaddingStatusbarView(itemView.home_none_padding) } - when (preview) { is Resource.Success -> { - previewAdapter.submitList(preview.value.second) - previewAdapter.hasMoreItems = preview.value.first - /*if (!.setItems( + if (true != previewAdapter?.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 - }*/ - - previewViewpager.isVisible = true - previewViewpagerText.isVisible = true - alternativeAccountPadding?.isVisible = false + previewHeader?.isVisible = true + } } - else -> { - previewAdapter.submitList(listOf()) - previewViewpager.setCurrentItem(0, false) - previewViewpager.isVisible = false - previewViewpagerText.isVisible = false - alternativeAccountPadding?.isVisible = true - //previewHeader.isVisible = false + previewAdapter?.setItems(listOf(), false) + previewViewpager?.setCurrentItem(0, false) + previewHeader?.isVisible = false } } + // previewViewpager?.postDelayed({ previewViewpager?.scr(100, 0) }, 1000) + //previewViewpager?.postInvalidate() } - private fun updateResume(resumeWatching: List) { - resumeHolder.isVisible = resumeWatching.isNotEmpty() - resumeAdapter.submitList(resumeWatching) + fun updateResume(resumeWatching: List) { + resumeHolder?.isVisible = resumeWatching.isNotEmpty() + resumeAdapter?.updateList(resumeWatching) - if ( - binding is FragmentHomeHeadBinding || - binding is FragmentHomeHeadTvBinding && - isLayout(EMULATOR) - ) { - val title = (binding as? FragmentHomeHeadBinding)?.homeWatchParentItemTitle - ?: (binding as? FragmentHomeHeadTvBinding)?.homeWatchParentItemTitle - - title?.setOnClickListener { - viewModel.popup( + if (!isTvSettings()) { + itemView.home_watch_parent_item_title?.setOnClickListener { + moreInfoClickCallback.invoke( HomeViewModel.ExpandableHomepageList( HomePageList( - title.text.toString(), + itemView.home_watch_parent_item_title?.text.toString(), resumeWatching, false ), 1, false - ), - deleteCallback = { - viewModel.deleteResumeWatching() - } + ) ) } } } - 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 } + 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 } if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog val textSum = items .mapNotNull { it.text }.joinToString() - viewModel.popup( + moreInfoClickCallback.invoke( HomeViewModel.ExpandableHomepageList( HomePageList( textSum, - list, + data.second, false ), 1, false - ), deleteCallback = { - viewModel.deleteBookmarks(list) - } + ) ) } } } - override fun onViewAttachedToWindow() { - previewViewpager.registerOnPageChangeCallback(previewCallback) - - binding.root.findViewTreeLifecycleOwner()?.apply { - observe(viewModel.preview) { - updatePreview(it) + fun setAvailableWatchStatusTypes(availableWatchStatusTypes: Pair, Set>) { + for ((chip, watch) in toggleList) { + chip?.apply { + isVisible = availableWatchStatusTypes.second.contains(watch) + isChecked = availableWatchStatusTypes.first.contains(watch) } - 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 29186e83..f296e53d 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,63 +2,102 @@ 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.fragment.app.Fragment +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.LoadResponse -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.R 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( - fragment: Fragment -) : NoStateAdapter(fragment) { + @LayoutRes val layout: Int = R.layout.home_scroll_view, + private val forceHorizontalPosters: Boolean? = null +) : RecyclerView.Adapter() { + private var items: MutableList = mutableListOf() var hasMoreItems: Boolean = false - 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 getItem(position: Int): LoadResponse? { + return items.getOrNull(position) } - 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 + fun setItems(newItems: List, hasNext: Boolean): Boolean { + val isSame = newItems.firstOrNull()?.url == items.firstOrNull()?.url + hasMoreItems = hasNext - val posterUrl = - if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl - ?: item.backgroundPosterUrl + val diffResult = DiffUtil.calculateDiff( + HomeScrollDiffCallback(this.items, 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 - } + items.clear() + items.addAll(newItems) - is HomeScrollViewTvBinding -> { - binding.homeScrollPreview.setImage(posterUrl) + + 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]) } } } + + 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 24ca4df2..edf58008 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,59 +1,38 @@ 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.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.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.mvvm.* 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.EnumSet -import java.util.concurrent.CopyOnWriteArrayList +import java.util.* import kotlin.collections.set class HomeViewModel : ViewModel() { @@ -93,17 +72,7 @@ class HomeViewModel : ViewModel() { } } - fun deleteResumeWatching() { - deleteAllResumeStateIds() - loadResumeWatching() - } - - fun deleteBookmarks(list: List) { - list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) } - loadStoredData() - } - - var repo: APIRepository? = null + private var repo: APIRepository? = null private val _apiName = MutableLiveData() val apiName: LiveData = _apiName @@ -114,7 +83,7 @@ class HomeViewModel : ViewModel() { private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { - return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) + return APIRepository(apis.first { it.hasMainPage }) } private val _availableWatchStatusTypes = @@ -126,20 +95,14 @@ class HomeViewModel : ViewModel() { private val _resumeWatching = MutableLiveData>() private val _preview = MutableLiveData>>>() - private val previewResponses = CopyOnWriteArrayList() + private val previewResponses = mutableListOf() private val previewResponsesAdded = mutableSetOf() val resumeWatching: LiveData> = _resumeWatching val preview: LiveData>>> = _preview - private fun loadResumeWatching() = viewModelScope.launchSafe { + 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) } @@ -152,7 +115,7 @@ class HomeViewModel : ViewModel() { } }?.distinctBy { it.first } ?: return@launchSafe - val length = WatchType.entries.size + val length = WatchType.values().size val currentWatchTypes = mutableSetOf() for (watch in watchStatusIds) { @@ -165,7 +128,6 @@ 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 @@ -174,13 +136,12 @@ 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( - - watchPrefNotNull to - currentWatchTypes, - + Pair( + watchPrefNotNull, + currentWatchTypes, ) + ) val list = withContext(Dispatchers.IO) { watchStatusIds.filter { watchPrefNotNull.contains(it.second) } @@ -191,11 +152,8 @@ class HomeViewModel : ViewModel() { } private var onGoingLoad: Job? = null - private var isCurrentlyLoadingName: String? = null - private fun loadAndCancel(api: MainAPI) { - //println("loaded ${api.name}") + private fun loadAndCancel(api: MainAPI?) { onGoingLoad?.cancel() - isCurrentlyLoadingName = api.name onGoingLoad = load(api) } @@ -297,12 +255,12 @@ class HomeViewModel : ViewModel() { } } - private fun load(api: MainAPI): Job = ioSafe { - repo = //if (api != null) { + private fun load(api: MainAPI?) = ioSafe { + repo = if (api != null) { APIRepository(api) - //} else { - // autoloadRepo() - //} + } else { + autoloadRepo() + } _apiName.postValue(repo?.name) _randomItems.postValue(listOf()) @@ -316,7 +274,6 @@ 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)) { @@ -328,13 +285,7 @@ class HomeViewModel : ViewModel() { val filteredList = context?.filterHomePageListByFilmQuality(list) ?: list expandable[list.name] = - ExpandableHomepageList( - filteredList.copy( - list = CopyOnWriteArrayList( - filteredList.list - ) - ), 1, home.hasNext - ) + ExpandableHomepageList(filteredList, 1, home.hasNext) } } @@ -349,7 +300,8 @@ 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 = @@ -385,142 +337,41 @@ 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) - } - } - - - 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") + fun loadAndCancel(preferredApiName: String?, forceReload: Boolean = true) = + viewModelScope.launchSafe { // 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 - // 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 (!forceReload && api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true) { + return@launchSafe } - val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { - // just set to random - if (fromUI) DataStoreHelper.currentHomePage = noneApi.name + setKey(USER_SELECTED_HOMEPAGE_API, 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) - if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name + setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name) } + // If the plugin isn't loaded yet. (Does not set the key) } else if (api == null) { - // 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!!) - } + loadAndCancel(noneApi) } else { - // if the api is found, then set it to it and save key - if (fromUI) DataStoreHelper.currentHomePage = api.name + setKey(USER_SELECTED_HOMEPAGE_API, 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 5b240693..d7c06c4e 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,73 +1,50 @@ 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 android.util.TypedValue +import androidx.fragment.app.Fragment 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.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.AppUtils.loadResult +import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.android.synthetic.main.fragment_library.* import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" enum class LibraryOpenerType(@StringRes val stringRes: Int) { - Default(R.string.action_default), + Default(R.string.default_subtitles), // TODO FIX AFTER MERGE Provider(R.string.none), Browser(R.string.browser), Search(R.string.search), @@ -86,8 +63,6 @@ data class ProviderLibraryData( class LibraryFragment : Fragment() { companion object { - - val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -98,85 +73,40 @@ 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 { - 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() + ): View? { + return inflater.inflate(R.layout.fragment_library, container, false) } override fun onSaveInstanceState(outState: Bundle) { - binding?.viewpager?.currentItem?.let { currentItem -> + 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) - fixPaddingStatusbar(binding?.searchStatusBarPadding) + context?.fixPaddingStatusbar(search_status_bar_padding) - 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) + sort_fab?.setOnClickListener { + 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) + }) } - // 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 { + main_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -192,19 +122,14 @@ class LibraryFragment : Fragment() { return true } - 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) - + libraryViewModel.sort(ListSorting.Query, newText) return true } }) libraryViewModel.reloadPages(false) - binding?.listSelector?.setOnClickListener { + list_selector?.setOnClickListener { val items = libraryViewModel.availableApiNames val currentItem = libraryViewModel.currentApiName.value @@ -218,25 +143,6 @@ 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 @@ -246,14 +152,12 @@ class LibraryFragment : Fragment() { syncId: SyncIdName, apiName: String? = null, ) { - 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 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 baseOptions = listOf( LibraryOpenerType.Default, LibraryOpenerType.None, @@ -263,7 +167,7 @@ class LibraryFragment : Fragment() { val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders - val savedSelection = getKey("$currentAccount/$LIBRARY_FOLDER", key) + val savedSelection = getKey(LIBRARY_FOLDER, key) val selectedIndex = when { savedSelection == null -> 0 @@ -298,84 +202,108 @@ class LibraryFragment : Fragment() { } setKey( - "$currentAccount/$LIBRARY_FOLDER", + LIBRARY_FOLDER, key, savedData, ) } } - binding?.providerSelector?.setOnClickListener { + provider_selector?.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } - binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) - - binding?.viewpager?.adapter = ViewpagerAdapter( - fragment = this, - { isScrollingDown: Boolean -> + viewpager?.setPageTransformer(LibraryScrollTransformer()) + viewpager?.adapter = + viewpager.adapter ?: ViewpagerAdapter(mutableListOf(), { isScrollingDown: Boolean -> if (isScrollingDown) { - binding?.sortFab?.shrink() - binding?.libraryRandom?.shrink() + sort_fab?.shrink() } else { - binding?.sortFab?.extend() - binding?.libraryRandom?.extend() + sort_fab?.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 as? MainActivity)?.loadPopup( - searchClickCallback.card, - load = false - ) - /*activity?.showPluginSelectionDialog( + when (searchClickCallback.action) { + SEARCH_ACTION_SHOW_METADATA -> { + activity?.showPluginSelectionDialog( syncId, syncName, searchClickCallback.card.apiName - )*/ - } + ) + } - SEARCH_ACTION_LOAD -> { - loadLibraryItem(syncName, syncId, searchClickCallback.card) + 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 + ) + } + } + } } } - } - binding?.apply { - viewpager.offscreenPageLimit = 2 - viewpager.reduceDragSensitivity() - searchBar.setExpanded(true) - } + viewpager?.offscreenPageLimit = 2 + viewpager?.reduceDragSensitivity() val startLoading = Runnable { - binding?.apply { - gridview.numColumns = context?.getSpanCount() ?: 3 - gridview.adapter = - context?.let { LoadingPosterAdapter(it, 6 * 3) } - libraryLoadingOverlay.isVisible = true - libraryLoadingShimmer.startShimmer() - emptyListTextview.isVisible = false - } + 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 } val stopLoading = Runnable { - binding?.apply { - gridview.adapter = null - libraryLoadingOverlay.isVisible = false - libraryLoadingShimmer.stopShimmer() - } + gridview?.adapter = null + library_loading_overlay?.isVisible = false + library_loading_shimmer?.stopShimmer() } val handler = Handler(Looper.getMainLooper()) @@ -386,109 +314,65 @@ class LibraryFragment : Fragment() { handler.removeCallbacks(startLoading) val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } - - 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) - } + 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) } - - (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 :( @@ -496,108 +380,16 @@ 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) { - binding?.viewpager?.adapter?.notifyDataSetChanged() + (viewpager.adapter as? ViewpagerAdapter)?.rebind() 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) \ No newline at end of file +class MenuSearchView(context: Context) : SearchView(context) { + override fun onActionViewCollapsed() { + super.onActionViewCollapsed() + } +} \ 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 c3cee183..8aafbdd6 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 com.lagradost.cloudstream3.R +import kotlinx.android.synthetic.main.library_viewpager_page.view.* import kotlin.math.roundToInt class LibraryScrollTransformer : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) { val padding = (-position * page.width).roundToInt() - page.findViewById(R.id.page_recyclerview).setPadding( + page.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 6c602e6c..5f64880c 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,14 +6,12 @@ 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 com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import kotlinx.coroutines.delay enum class ListSorting(@StringRes val stringRes: Int) { Query(R.string.none), @@ -23,20 +21,11 @@ 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 @@ -47,12 +36,12 @@ class LibraryViewModel : ViewModel() { get() = SyncApis.filter { it.hasAccount() } var currentSyncApi = availableSyncApis.let { allApis -> - val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") + val lastSelection = getKey(LAST_SYNC_API_KEY) availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull() } private set(value) { field = value - setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name) + setKey(LAST_SYNC_API_KEY, field?.name) } val availableApiNames: List @@ -70,21 +59,13 @@ class LibraryViewModel : ViewModel() { reloadPages(true) } - 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) { + fun sort(method: ListSorting, query: String? = null) { + val currentList = pages.value ?: return currentSortingMethod = method - DataStoreHelper.librarySortingMode = method.ordinal - - items.forEach { page -> + (currentList as? Resource.Success)?.value?.forEachIndexed { _, page -> page.sort(method, query) } - _pages.postValue(Resource.Success(items)) + _pages.postValue(currentList) } fun reloadPages(forceReload: Boolean) { @@ -105,6 +86,8 @@ 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 { @@ -114,24 +97,8 @@ class LibraryViewModel : ViewModel() { ) } - 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) - } + _pages.postValue(Resource.Success(pages)) } } } - - init { - MainActivity.reloadLibraryEvent += ::reloadPages - } - - override fun onCleared() { - MainActivity.reloadLibraryEvent -= ::reloadPages - super.onCleared() - } -} +} \ No newline at end of file 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 160fbe2b..a637133b 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,7 +5,15 @@ 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 b2de307f..2435f8be 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,21 +3,23 @@ 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.AppContextUtils +import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.UIHelper.toPx +import kotlinx.android.synthetic.main.search_result_grid_expanded.view.* import kotlin.math.roundToInt @@ -26,15 +28,12 @@ class PageAdapter( private val resView: AutofitRecyclerView, val clickCallback: (SearchClickCallback) -> Unit ) : - AppContextUtils.DiffAdapter(items) { + AppUtils.DiffAdapter(items) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return LibraryItemViewHolder( - SearchResultGridExpandedBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + LayoutInflater.from(parent.context) + .inflate(R.layout.search_result_grid_expanded, parent, false) ) } @@ -58,8 +57,8 @@ class PageAdapter( } } - inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) : - RecyclerView.ViewHolder(binding.root) { + inner class LibraryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val cardView: ImageView = itemView.imageView private val compactView = false//itemView.context.getGridIsCompact() private val coverHeight: Int = @@ -86,12 +85,11 @@ class PageAdapter( val fg = getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor)) - binding.textRating.apply { + itemView.text_rating.apply { setTextColor(ColorStateList.valueOf(fg)) } - binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg) - binding.textRating.backgroundTintList = ColorStateList.valueOf(bg) - binding.watchProgress.apply { + itemView.text_rating_holder?.backgroundTintList = ColorStateList.valueOf(bg) + itemView.watchProgress?.apply { progressTintList = ColorStateList.valueOf(fg) progressBackgroundTintList = ColorStateList.valueOf(bg) } @@ -101,7 +99,7 @@ class PageAdapter( // See searchAdaptor for this, it basically fixes the height if (!compactView) { - binding.imageView.apply { + cardView.apply { layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, coverHeight @@ -110,13 +108,23 @@ class PageAdapter( } val showProgress = item.episodesCompleted != null && item.episodesTotal != null - binding.watchProgress.isVisible = showProgress + itemView.watchProgress.isVisible = showProgress if (showProgress) { - binding.watchProgress.max = item.episodesTotal!! - binding.watchProgress.progress = item.episodesCompleted!! + itemView.watchProgress.max = item.episodesTotal!! + itemView.watchProgress.progress = item.episodesCompleted!! } - binding.imageText.text = item.name + 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" + } } } } \ 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 0110187f..33a40386 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,124 +1,90 @@ 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.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView 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 - -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) - } - } -} +import kotlinx.android.synthetic.main.library_viewpager_page.view.* class ViewpagerAdapter( - fragment: Fragment, + var pages: List, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : 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) +) : 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) ) } - 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) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is PageViewHolder -> { + holder.bind(pages[position], unbound.remove(position)) + } + } } - override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { - val binding = holder.view - if (binding !is LibraryViewpagerPageBinding) return + 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) + } - binding.pageRecyclerview.tag = position - binding.pageRecyclerview.apply { - spanCount = - binding.root.context.getSpanCount() ?: 3 - if (adapter == null) { // || rebind + 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) { // 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 - doOnAttach { - adapter = PageAdapter( - item.items.toMutableList(), - this, + itemViewTest.page_recyclerview?.doOnAttach { + itemViewTest.page_recyclerview?.adapter = PageAdapter( + page.items.toMutableList(), + itemViewTest.page_recyclerview, clickCallback ) } } else { - (adapter as? PageAdapter)?.updateList(item.items) - // scrollToPosition(0) + (itemViewTest.page_recyclerview?.adapter as? PageAdapter)?.updateList(page.items) + itemViewTest.page_recyclerview?.scrollToPosition(0) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + itemViewTest.page_recyclerview.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, 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 { - onFlingListener = object : OnFlingListener() { + itemViewTest.page_recyclerview.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 ee987f44..21047db3 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,60 +1,49 @@ package com.lagradost.cloudstream3.ui.player import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter +import android.content.* 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.util.Log +import android.support.v4.media.session.MediaSessionCompat 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.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.media.session.MediaButtonReceiver import androidx.preference.PreferenceManager import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.github.rubensousa.previewseekbar.PreviewBar -import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +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.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.AppContextUtils -import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus -import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.AppUtils +import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus 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), @@ -83,15 +72,9 @@ 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 open var layout: Int = R.layout.fragment_player + protected var layout: Int = R.layout.fragment_player open fun nextEpisode() { throw NotImplementedError() @@ -101,13 +84,11 @@ abstract class AbstractPlayerFragment( throw NotImplementedError() } - open fun playerPositionChanged(position: Long, duration: Long) { + open fun playerPositionChanged(posDur: Pair) { throw NotImplementedError() } - open fun playerStatusChanged(){} - - open fun playerDimensionsLoaded(width: Int, height: Int) { + open fun playerDimensionsLoaded(widthHeight: Pair) { throw NotImplementedError() } @@ -143,10 +124,8 @@ abstract class AbstractPlayerFragment( } } - private fun updateIsPlaying( - wasPlaying: CSPlayerLoading, - isPlaying: CSPlayerLoading - ) { + private fun updateIsPlaying(playing: Pair) { + val (wasPlaying, isPlaying) = playing val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying @@ -154,15 +133,15 @@ abstract class AbstractPlayerFragment( isBuffering = CSPlayerLoading.IsBuffering == isPlaying if (isBuffering) { - playerPausePlayHolderHolder?.isVisible = false - playerBuffering?.isVisible = true + player_pause_play_holder_holder?.isVisible = false + player_buffering?.isVisible = true } else { - playerPausePlayHolderHolder?.isVisible = true - playerBuffering?.isVisible = false + player_pause_play_holder_holder?.isVisible = true + player_buffering?.isVisible = false if (wasPlaying != isPlaying) { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) - val drawable = playerPausePlay?.drawable + player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) + val drawable = player_pause_play?.drawable var startedAnimation = false if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { @@ -184,33 +163,28 @@ abstract class AbstractPlayerFragment( // somehow the phone is wacked if (!startedAnimation) { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) + player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) } } else { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) + player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) } } canEnterPipMode = isPlayingRightNow && hasPipModeSupport - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPIPMode) { activity?.let { act -> - PlayerPipHelper.updatePIPModeActions( - act, - isPlayingRightNow, - player.getAspectRatio() - ) + PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow) } } } 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. - piphide?.isVisible = false + player_holder?.alpha = 0f pipReceiver = object : BroadcastReceiver() { override fun onReceive( context: Context, @@ -220,29 +194,28 @@ abstract class AbstractPlayerFragment( return } player.handleEvent( - CSPlayerEvent.entries[intent.getIntExtra( + CSPlayerEvent.values()[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(isPlayingValue, isPlayingValue) + updateIsPlaying(Pair(isPlayingValue, isPlayingValue)) } else { // Restore the full-screen UI. - piphide?.isVisible = true + player_holder?.alpha = 1f exitedPipMode() pipReceiver?.let { - // Prevents java.lang.IllegalArgumentException: Receiver not registered - normalSafeApiCall { - activity?.unregisterReceiver(it) - } + activity?.unregisterReceiver(it) } activity?.hideSystemUI() this.view?.let { UIHelper.hideKeyboard(it) } @@ -262,20 +235,22 @@ abstract class AbstractPlayerFragment( private fun requestAudioFocus() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + activity?.requestLocalAudioFocus(AppUtils.getFocusRequest()) } } - open fun playerError(exception: Throwable) { + open fun playerError(exception: Exception) { 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 ) @@ -295,21 +270,18 @@ 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", @@ -318,14 +290,12 @@ abstract class AbstractPlayerFragment( } } } - is InvalidFileException -> { showToast( "${ctx.getString(R.string.source_error)}\n${exception.message}", gotoNext = true ) } - else -> { exception.message?.let { showToast( @@ -343,25 +313,29 @@ abstract class AbstractPlayerFragment( } } - @SuppressLint("UnsafeOptInUsageError") private fun playerUpdated(player: Any?) { if (player is ExoPlayer) { context?.let { ctx -> - mMediaSession?.release() - mMediaSession = MediaSession.Builder(ctx, player) - // Ensure unique ID for concurrent players - .setId(unixTimeMs.toString()) - .build() + 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 + } } // Necessary for multiple combined videos - playerView?.setShowMultiWindowTimeBar(true) - playerView?.player = player - playerView?.performClick() + player_view?.setShowMultiWindowTimeBar(true) + player_view?.player = player + player_view?.performClick() } } - private var mMediaSession: MediaSession? = null + private var mediaSessionConnector: MediaSessionConnector? = null + private var mMediaSessionCompat: MediaSessionCompat? = null // this can be used in the future for players other than exoplayer //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { @@ -384,174 +358,39 @@ 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) - } - 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") + @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - resizeMode = DataStoreHelper.resizeMode + resizeMode = getKey(RESIZE_MODE_KEY) ?: 0 resize(resizeMode, false) player.releaseCallbacks() player.initCallbacks( - eventHandler = ::mainCallback, + playerUpdated = ::playerUpdated, + updateIsPlaying = ::updateIsPlaying, + playerError = ::playerError, + requestAutoFocus = ::requestAudioFocus, + nextEpisode = ::nextEpisode, + prevEpisode = ::prevEpisode, + playerPositionChanged = ::playerPositionChanged, + playerDimensionsLoaded = ::playerDimensionsLoaded, 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) { - // 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) + subView = player_view?.findViewById(R.id.exo_subtitles) subStyle = SubtitlesFragment.getCurrentSavedStyle() - 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 - ) - ) - } - }) + player.initSubtitles(subView, subtitle_holder, subStyle) SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged @@ -597,9 +436,6 @@ abstract class AbstractPlayerFragment( playerEventListener = null keyEventListener = null canEnterPipMode = false - mMediaSession?.release() - mMediaSession = null - playerView?.player = null SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged keepScreenOn(false) @@ -607,26 +443,25 @@ abstract class AbstractPlayerFragment( } fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resizeMode = (resizeMode + 1) % PlayerResize.values().size resize(resizeMode, true) } fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.entries[resize], showToast) + resize(PlayerResize.values()[resize], showToast) } - @SuppressLint("UnsafeOptInUsageError") fun resize(resize: PlayerResize, showToast: Boolean) { - DataStoreHelper.resizeMode = resize.ordinal + setKey(RESIZE_MODE_KEY, resize.ordinal) val type = when (resize) { PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM } - playerView?.resizeMode = type + player_view?.resizeMode = type if (showToast) - showToast(resize.nameRes, Toast.LENGTH_SHORT) + showToast(activity, resize.nameRes, Toast.LENGTH_SHORT) } override fun onStop() { @@ -647,13 +482,6 @@ abstract class AbstractPlayerFragment( container: ViewGroup?, savedInstanceState: Bundle? ): View? { - 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 + return inflater.inflate(layout, container, false) } } \ 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 86d67b28..4772a7f1 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,77 +1,45 @@ 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.ExtractorLinkType +import com.lagradost.cloudstream3.utils.ExtractorUri 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 @@ -79,33 +47,15 @@ import javax.net.ssl.SSLSession const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" -/** 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 +/** Cache */ -/** - * 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 @@ -128,16 +78,7 @@ class CS3IPlayer : IPlayer { * */ data class MediaItemSlice( val mediaItem: MediaItem, - 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, + val durationUs: Long ) override fun getDuration(): Long? = exoPlayer?.duration @@ -151,24 +92,80 @@ 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 - private var eventHandler: ((PlayerEvent) -> Unit)? = null + /** Fired when seeking the player or on requestedListeningPercentages, + * used to make things appear on que + * position, duration */ + private var playerPositionChanged: ((Pair) -> Unit)? = null - fun event(event: PlayerEvent) { - eventHandler?.invoke(event) - } + 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 override fun releaseCallbacks() { - eventHandler = null + 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 } override fun initCallbacks( - eventHandler: ((PlayerEvent) -> Unit), + playerUpdated: (Any?) -> Unit, + updateIsPlaying: ((Pair) -> Unit)?, + requestAutoFocus: (() -> Unit)?, + playerError: ((Exception) -> Unit)?, + playerDimensionsLoaded: ((Pair) -> 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.eventHandler = eventHandler + this.playerPositionChanged = playerPositionChanged + this.nextEpisode = nextEpisode + this.prevEpisode = prevEpisode + this.subtitlesUpdates = subtitlesUpdates + this.embeddedSubtitlesFetched = embeddedSubtitlesFetched + this.onTracksInfoChanged = onTracksInfoChanged + this.onTimestampInvoked = onTimestampInvoked + this.onTimestampSkipped = onTimestampSkipped } // I know, this is not a perfect solution, however it works for fixing subs @@ -177,7 +174,7 @@ class CS3IPlayer : IPlayer { try { Handler(it).post { try { - seekTime(1L, source = PlayerEventSource.Player) + seekTime(1L) } catch (e: Exception) { logError(e) } @@ -192,14 +189,6 @@ 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, @@ -208,8 +197,7 @@ class CS3IPlayer : IPlayer { startPosition: Long?, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean?, - preview: Boolean, + autoPlay: Boolean? ) { Log.i(TAG, "loadPlayer") if (sameEpisode) { @@ -228,30 +216,11 @@ 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) { @@ -259,7 +228,7 @@ class CS3IPlayer : IPlayer { subtitleHelper.setAllSubtitles(subtitles) } - private var currentSubtitles: SubtitleData? = null + var currentSubtitles: SubtitleData? = null private fun List.getTrack(id: String?): Pair? { if (id == null) return null @@ -404,7 +373,7 @@ class CS3IPlayer : IPlayer { if (subtitle == null) { trackSelector.setParameters( trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) + .setPreferredTextLanguage(null) .clearOverridesOfType(TRACK_TYPE_TEXT) ) } else { @@ -413,7 +382,6 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") return@let true } - SubtitleStatus.IS_ACTIVE -> { Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") @@ -422,7 +390,6 @@ class CS3IPlayer : IPlayer { .apply { val track = getTextTrack(subtitle.getId()) if (track != null) { - setTrackTypeDisabled(TRACK_TYPE_TEXT, false) setOverrideForType( TrackSelectionOverride( track.first, @@ -440,7 +407,6 @@ class CS3IPlayer : IPlayer { // }, 1) //} } - SubtitleStatus.NOT_FOUND -> { Log.i(TAG, "setPreferredSubtitles NOT_FOUND") return@let true @@ -451,7 +417,7 @@ class CS3IPlayer : IPlayer { } ?: false } - private var currentSubtitleOffset: Long = 0 + var currentSubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset @@ -459,7 +425,7 @@ class CS3IPlayer : IPlayer { } override fun getSubtitleOffset(): Long { - return currentSubtitleOffset + return currentSubtitleOffset //currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset } override fun getCurrentPreferredSubtitle(): SubtitleData? { @@ -470,12 +436,6 @@ 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) } @@ -486,7 +446,7 @@ class CS3IPlayer : IPlayer { exoPlayer?.let { exo -> playbackPosition = exo.currentPosition - currentWindow = exo.currentMediaItemIndex + currentWindow = exo.currentWindowIndex isPlaying = exo.isPlaying } } @@ -497,11 +457,7 @@ class CS3IPlayer : IPlayer { if (saveTime) updatedTime() - exoPlayer?.apply { - playWhenReady = false - stop() - release() - } + exoPlayer?.release() //simpleCache?.release() currentTextRenderer = null @@ -513,14 +469,14 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "onStop") saveData() - handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + exoPlayer?.pause() //releasePlayer() } override fun onPause() { Log.i(TAG, "onPause") saveData() - handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + exoPlayer?.pause() //releasePlayer() } @@ -530,7 +486,6 @@ class CS3IPlayer : IPlayer { } override fun release() { - imageGenerator.release() releasePlayer() } @@ -545,15 +500,12 @@ class CS3IPlayer : IPlayer { **/ var preferredAudioTrackLanguage: String? = null get() { - return field ?: getKey( - "$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", - field - )?.also { + return field ?: getKey(PREFERRED_AUDIO_LANGUAGE_KEY, field)?.also { field = it } } set(value) { - setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value) + setKey(PREFERRED_AUDIO_LANGUAGE_KEY, value) field = value } @@ -583,17 +535,15 @@ 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" - ) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization + ) + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -601,16 +551,57 @@ class CS3IPlayer : IPlayer { } private fun Context.createOfflineSource(): DataSource.Factory { - return DefaultDataSource.Factory(this, DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT)) + return DefaultDataSourceFactory(this, 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 { deleteFileOnExit(it) }, // Ensures always fresh file + ).also { it.deleteOnExit() }, // Ensures always fresh file LeastRecentlyUsedCacheEvictor(cacheSize), databaseProvider ) @@ -637,7 +628,12 @@ class CS3IPlayer : IPlayer { private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { val trackSelector = DefaultTrackSelector(context) - trackSelector.parameters = trackSelector.buildUponParameters() + 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) // 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) @@ -670,11 +666,7 @@ class CS3IPlayer : IPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> - DefaultRenderersFactory(context).apply { - setEnableDecoderFallback(true) - // Enable Ffmpeg extension - setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) - }.createRenderers( + DefaultRenderersFactory(context).createRenderers( eventHandler, videoRendererEventListener, audioRendererEventListener, @@ -682,13 +674,13 @@ class CS3IPlayer : IPlayer { metadataRendererOutput ).map { if (it is TextRenderer) { - val currentTextRenderer = CustomTextRenderer( + currentTextRenderer = CustomTextRenderer( subtitleOffset, textRendererOutput, eventHandler.looper, CustomSubtitleDecoderFactory() - ).also { renderer -> this.currentTextRenderer = renderer } - currentTextRenderer + ) + currentTextRenderer!! } else it }.toTypedArray() } @@ -699,7 +691,7 @@ class CS3IPlayer : IPlayer { ) ) // Allows any seeking to be +- 0.3s to allow for faster seeking - .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs)) + .setSeekParameters(SeekParameters(300_000, 300_000)) .setLoadControl( DefaultLoadControl.Builder() .setTargetBufferBytes( @@ -709,10 +701,6 @@ 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) { @@ -732,33 +720,15 @@ 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) { - 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) - } + factory.createMediaSource(mediaItemSlices.first().mediaItem) } else { val source = ConcatenatingMediaSource() - mediaItemSlices.map { item -> + mediaItemSlices.map { source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( - factory.createMediaSource(item.mediaItem), - item.durationUs + factory.createMediaSource(it.mediaItem), + it.durationUs ) ) } @@ -784,65 +754,50 @@ 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 + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { + if (lastTimeStamp.startMs <= position && position < lastTimeStamp.endMs) { return lastTimeStamp } } return null } - fun updatedTime( - writePosition: Long? = null, - source: PlayerEventSource = PlayerEventSource.Player - ) { - val position = writePosition ?: exoPlayer?.currentPosition - - getCurrentTimestamp(position)?.let { timestamp -> - event(TimestampInvokedEvent(timestamp, source)) + fun updatedTime(writePosition: Long? = null) { + getCurrentTimestamp(writePosition)?.let { timestamp -> + onTimestampInvoked?.invoke(timestamp) } + val position = writePosition ?: exoPlayer?.currentPosition val duration = exoPlayer?.contentDuration if (duration != null && position != null) { - event( - PositionEvent( - source, - fromMs = exoPlayer?.currentPosition ?: 0, - position, - duration - ) - ) + playerPositionChanged?.invoke(Pair(position, duration)) } } - override fun seekTime(time: Long, source: PlayerEventSource) { - exoPlayer?.seekTime(time, source) + override fun seekTime(time: Long) { + exoPlayer?.seekTime(time) } - override fun seekTo(time: Long, source: PlayerEventSource) { - updatedTime(time, source) + override fun seekTo(time: Long) { + updatedTime(time) exoPlayer?.seekTo(time) } - private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) { - updatedTime(currentPosition + time, source) + private fun ExoPlayer.seekTime(time: Long) { + updatedTime(currentPosition + time) seekTo(currentPosition + time) } - override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) { + override fun handleEvent(event: CSPlayerEvent) { 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 @@ -853,51 +808,33 @@ class CS3IPlayer : IPlayer { volume = 0f } } - CSPlayerEvent.PlayPauseToggle -> { if (isPlaying) { - handleEvent(CSPlayerEvent.Pause, source) + pause() } else { - handleEvent(CSPlayerEvent.Play, source) + play() } } - - 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.SeekForward -> seekTime(seekActionTime) + CSPlayerEvent.SeekBack -> seekTime(-seekActionTime) + CSPlayerEvent.NextEpisode -> nextEpisode?.invoke() + CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke() CSPlayerEvent.SkipCurrentChapter -> { //val dur = this@CS3IPlayer.getDuration() ?: return@apply getCurrentTimestamp()?.let { lastTimeStamp -> if (lastTimeStamp.skipToNextEpisode) { - handleEvent(CSPlayerEvent.NextEpisode, source) + handleEvent(CSPlayerEvent.NextEpisode) } else { seekTo(lastTimeStamp.endMs + 1L) } - event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) + onTimestampSkipped?.invoke(lastTimeStamp) } } } } - } catch (t: Throwable) { - Log.e(TAG, "handleEvent error", t) - event(ErrorEvent(t)) + } catch (e: Exception) { + Log.e(TAG, "handleEvent error", e) + playerError?.invoke(e) } } @@ -910,7 +847,7 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val maxVideoHeight = settingsManager.getInt( - context.getString(if (context.isUsingMobileData()) com.lagradost.cloudstream3.R.string.quality_pref_mobile_data_key else com.lagradost.cloudstream3.R.string.quality_pref_key), + context.getString(com.lagradost.cloudstream3.R.string.quality_pref_key), Int.MAX_VALUE ) @@ -936,14 +873,18 @@ class CS3IPlayer : IPlayer { requestSubtitleUpdate = ::reloadSubs - event(PlayerAttachedEvent(exoPlayer)) + playerUpdated?.invoke(exoPlayer) exoPlayer?.prepare() exoPlayer?.let { exo -> - event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) + updateIsPlaying?.invoke( + Pair( + CSPlayerLoading.IsBuffering, + CSPlayerLoading.IsBuffering + ) + ) isPlaying = exo.isPlaying } - exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { normalSafeApiCall { @@ -973,24 +914,22 @@ class CS3IPlayer : IPlayer { format.id!!, SubtitleOrigin.EMBEDDED_IN_VIDEO, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, - emptyMap(), - format.language + emptyMap() ) } - event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) - event(TracksChangedEvent()) - event(SubtitlesUpdatedEvent()) + embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks) + onTracksInfoChanged?.invoke() + subtitlesUpdates?.invoke() } } - //fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> - 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 + 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 ) ) isPlaying = exo.isPlaying @@ -1000,7 +939,6 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { onRenderFirst() } - else -> {} } @@ -1010,19 +948,23 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { } - Player.STATE_ENDED -> { - event(VideoEndedEvent()) + // 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) + } } - Player.STATE_BUFFERING -> { - updatedTime(source = PlayerEventSource.Player) + updatedTime() } - Player.STATE_IDLE -> { - + // IDLE } - else -> Unit } } @@ -1032,21 +974,12 @@ 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. - 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)) - } + if (error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + && exoPlayer?.duration != TIME_UNSET + ) { + exoPlayer?.prepare() + } else { + playerError?.invoke(error) } super.onPlayerError(error) @@ -1059,7 +992,7 @@ class CS3IPlayer : IPlayer { override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) if (isPlaying) { - event(RequestAudioFocusEvent()) + requestAutoFocus?.invoke() onRenderFirst() } } @@ -1070,11 +1003,7 @@ 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( @@ -1082,49 +1011,42 @@ class CS3IPlayer : IPlayer { true ) == true ) { - handleEvent( - CSPlayerEvent.NextEpisode, - source = PlayerEventSource.Player - ) + handleEvent(CSPlayerEvent.NextEpisode) } } - Player.STATE_BUFFERING -> { - updatedTime(source = PlayerEventSource.Player) + updatedTime() } - Player.STATE_IDLE -> { // IDLE } - else -> Unit } } override fun onVideoSizeChanged(videoSize: VideoSize) { super.onVideoSizeChanged(videoSize) - event(ResizedEvent(height = videoSize.height, width = videoSize.width)) + playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height)) } override fun onRenderedFirstFrame() { + updatedTime() super.onRenderedFirstFrame() onRenderFirst() - updatedTime(source = PlayerEventSource.Player) } }) - } catch (t: Throwable) { - Log.e(TAG, "loadExo error", t) - event(ErrorEvent(t)) + } catch (e: Exception) { + Log.e(TAG, "loadExo error", e) + playerError?.invoke(e) } } private var lastTimeStamps: List = emptyList() - override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> - updatedTime(source = PlayerEventSource.Player) + updatedTime() //if (payload is EpisodeSkip.SkipStamp) // this should always be true // onTimestampInvoked?.invoke(payload) } @@ -1134,47 +1056,46 @@ class CS3IPlayer : IPlayer { ?.setDeleteAfterDelivery(false) ?.send() } - updatedTime(source = PlayerEventSource.Player) + updatedTime() } fun onRenderFirst() { - 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 (!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 (invalid) { - releasePlayer(saveTime = false) - event(ErrorEvent(InvalidFileException("Too short playback"))) - return - } + if (invalid) { + releasePlayer(saveTime = false) + playerError?.invoke(InvalidFileException("Too short playback")) + return + } - 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() + 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() } - .setLooper(Looper.getMainLooper()) - .setPosition(contentDuration * percentage / 100) - // .setPayload(customPayloadData) - .setDeleteAfterDelivery(false) - .send() } } } @@ -1197,9 +1118,9 @@ class CS3IPlayer : IPlayer { subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) - } catch (t: Throwable) { - Log.e(TAG, "loadOfflinePlayer error", t) - event(ErrorEvent(t)) + } catch (e: Exception) { + Log.e(TAG, "loadOfflinePlayer error", e) + playerError?.invoke(e) } } @@ -1214,7 +1135,7 @@ class CS3IPlayer : IPlayer { .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) - .setSelectionFlags(0) + .setSelectionFlags(SELECTION_FLAG_DEFAULT) .build() when (sub.origin) { SubtitleOrigin.DOWNLOADED_FILE -> { @@ -1226,7 +1147,6 @@ class CS3IPlayer : IPlayer { null } } - SubtitleOrigin.URL -> { if (onlineSourceFactory != null) { activeSubtitles.add(sub) @@ -1239,7 +1159,6 @@ class CS3IPlayer : IPlayer { null } } - SubtitleOrigin.EMBEDDED_IN_VIDEO -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) @@ -1258,7 +1177,6 @@ class CS3IPlayer : IPlayer { return exoPlayer != null } - @SuppressLint("UnsafeOptInUsageError") private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { Log.i(TAG, "loadOnlinePlayer $link") try { @@ -1275,37 +1193,18 @@ class CS3IPlayer : IPlayer { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) } - 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 mime = if (link.isM3u8) { + MimeTypes.APPLICATION_M3U8 + } else { + MimeTypes.VIDEO_MP4 } - - val mediaItems = when (link) { - is ExtractorLinkPlayList -> link.playlist.map { + val mediaItems = if (link is ExtractorLinkPlayList) { + link.playlist.map { MediaItemSlice(getMediaItem(mime, it.url), it.durationUs) } - - 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( + } else { + listOf( // Single sliced list with unset length MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) ) @@ -1331,20 +1230,20 @@ class CS3IPlayer : IPlayer { } loadExo(context, mediaItems, subSources, cacheFactory) - } catch (t: Throwable) { - Log.e(TAG, "loadOnlinePlayer error", t) - event(ErrorEvent(t)) + } catch (e: Exception) { + Log.e(TAG, "loadOnlinePlayer error", e) + playerError?.invoke(e) } } override fun reloadPlayer(context: Context) { Log.i(TAG, "reloadPlayer") - releasePlayer(false) + exoPlayer?.release() 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 07ce413e..690d3706 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,38 +2,24 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.util.Log -import androidx.annotation.OptIn import androidx.preference.PreferenceManager -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.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 com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import org.mozilla.universalchardet.UniversalDetector import java.nio.ByteBuffer import java.nio.charset.Charset -/** - * @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 { +class CustomDecoder : SubtitleDecoder { companion object { fun updateForcedEncoding(context: Context) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -73,7 +59,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { RegexOption.IGNORE_CASE ), ) - val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\s]*?[])}]\s*""")) + val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\d\s]*?[])}]\s*""")) //https://emptycharacter.com/ //https://www.fileformat.info/info/unicode/char/200b/index.htm @@ -153,7 +139,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : 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 fully on mimetype + // this way we read the subtitle file and decide what decoder to use instead of relying 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 { @@ -162,31 +148,8 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { (str.startsWith( "[Script Info]", ignoreCase = true - ) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder(fallbackFormat?.initializationData) + ) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder() 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( @@ -263,7 +226,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : 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) @@ -284,6 +246,28 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { } override fun createDecoder(format: Format): SubtitleDecoder { - return CustomDecoder(format) + 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() + //} } } \ 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 f2b863fb..d3f4171a 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,12 +1,9 @@ package com.lagradost.cloudstream3.ui.player import android.os.Looper -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.text.SubtitleDecoderFactory -import androidx.media3.exoplayer.text.TextOutput +import com.google.android.exoplayer2.text.SubtitleDecoderFactory +import com.google.android.exoplayer2.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 c7db7d04..baf7ed52 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,15 +1,11 @@ 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.SubtitleUtils.cleanDisplayName -import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.VideoDownloadManager import kotlin.math.max import kotlin.math.min @@ -18,7 +14,6 @@ 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 @@ -57,50 +52,44 @@ class DownloadFileGenerator( override suspend fun generateLinks( clearCache: Boolean, - type: LoadType, + isCasting: Boolean, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int + offset: Int, ): Boolean { val meta = episodes[currentIndex + offset] + callback(Pair(null, meta)) - 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) + 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 (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 c38160c2..dc1bbba3 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.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 +import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.UIHelper.navigate + +const val DTAG = "PlayerActivity" class DownloadedPlayerActivity : AppCompatActivity() { - private val dTAG = "DownloadedPlayerAct" - - override fun dispatchKeyEvent(event: KeyEvent): Boolean { + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { CommonActivity.dispatchKeyEvent(this, event)?.let { return it } @@ -33,18 +33,54 @@ 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?) { - super.onCreate(savedInstanceState) + Log.i(DTAG, "onCreate") + CommonActivity.loadThemes(this) + super.onCreate(savedInstanceState) CommonActivity.init(this) + setContentView(R.layout.empty_layout) - Log.i(dTAG, "onCreate") val data = intent.data if (intent?.action == Intent.ACTION_SEND) { - val extraText = normalSafeApiCall { // I dont trust android + val extraText = try { // 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 @@ -52,29 +88,22 @@ class DownloadedPlayerActivity : AppCompatActivity() { // idk what I am doing, just hope any of these work if (item?.uri != null) - playUri(this, item.uri) + playUri(item.uri) else if (url != null) - playLink(this, url) + playLink(url) else if (data != null) - playUri(this, data) + playUri(data) else if (extraText != null) - playLink(this, extraText) + playLink(extraText) else { finish() return } } else if (data?.scheme == "content") { - playUri(this, data) + playUri(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 ec485f1c..7c19e97d 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,17 +37,14 @@ class ExtractorLinkGenerator( override suspend fun generateLinks( clearCache: Boolean, - type: LoadType, + isCasting: Boolean, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int ): Boolean { subtitles.forEach(subtitleCallback) - val allowedTypes = type.toSet() links.forEach { - if(allowedTypes.contains(it.type)) { - callback.invoke(it to null) - } + 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 b2e80749..8d28fd9d 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,61 +2,44 @@ 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.Configuration +import android.content.res.Resources 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.text.format.DateUtils +import android.util.DisplayMetrics 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 androidx.annotation.OptIn -import android.widget.LinearLayout +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView 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.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey 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.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.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe @@ -66,13 +49,31 @@ 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 kotlin.math.abs -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min -import kotlin.math.round +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.* const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage @@ -82,49 +83,40 @@ 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 var playerBinding: PlayerCustomLayoutBinding? = null - - private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + protected open var isTv = 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 - - /** - * 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 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 { @@ -143,6 +135,19 @@ 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 @@ -169,21 +174,6 @@ 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() } @@ -194,7 +184,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { open fun openOnlineSubPicker( context: Context, - loadResponse: LoadResponse?, + imdbId: Long?, dismissCallback: (() -> Unit) ) { throw NotImplementedError() @@ -216,24 +206,24 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isShowing) { updateUIVisibility() } else { - playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) + player_holder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - playerBinding?.playerVideoTitle?.let { + player_video_title?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } - playerBinding?.playerVideoTitleRez?.let { + player_video_title_rez?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() - playerBinding?.bottomPlayerBar?.let { + bottom_player_bar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { duration = 200 start() @@ -246,11 +236,10 @@ 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) -((playerBinding?.bottomPlayerBar?.height?.toFloat() + val move = if (isShowing) -((bottom_player_bar?.height?.toFloat() ?: 0f) + 40.toPx) else -sStyle.elevation.toPx.toFloat() ObjectAnimator.ofFloat(sView, "translationY", move).apply { duration = 200 @@ -259,118 +248,42 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() - - playerBinding?.apply { - playerOpenSource.let { - ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { - duration = 200 - start() - } + player_open_source?.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() { - 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() - } - } - } - } + player_subtitle_offset_btt?.isGone = player.getCurrentPreferredSubtitle() == null } protected fun enterFullscreen() { @@ -382,10 +295,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.window?.attributes = params } } - updateOrientation() + if (lockRotation) + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } protected fun exitFullscreen() { + activity?.showSystemUI() //if (lockRotation) activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER @@ -397,7 +312,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT } activity?.window?.attributes = lp - activity?.showSystemUI() } override fun onResume() { @@ -414,8 +328,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun setPlayBackSpeed(speed: Float) { try { - DataStoreHelper.playBackSpeed = speed - playerBinding?.playerSpeedBtt?.text = + setKey(PLAYBACK_SPEED_KEY, speed) + player_speed_btt?.text = getString(R.string.player_speed_text_format).format(speed) .replace(".0x", "x") } catch (e: Exception) { @@ -431,68 +345,67 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } private fun showSubtitleOffsetDialog() { - val ctx = context ?: return + context?.let { ctx -> + val builder = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + .setView(R.layout.subtitle_offset) + val dialog = builder.create() + dialog.show() - val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) + val beforeOffset = subtitleDelay - val builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - .setView(binding.root) - val dialog = builder.create() - dialog.show() + 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 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) + input.doOnTextChanged { text, _, _, _ -> + text?.toString()?.toLongOrNull()?.let { + subtitleDelay = it + when { + it > 0L -> { + context?.getString(R.string.subtitle_offset_extra_hint_later_format) + ?.format(it) } - - time < 0L -> { - txt(R.string.subtitle_offset_extra_hint_before_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) } - else -> { - txt(R.string.subtitle_offset_extra_hint_none_format) + null } + }?.let { str -> + subTitle.text = str } - subtitleOffsetSubTitle.setText(str) } } - subtitleOffsetInput.text = - Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) + input.text = Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) val buttonChange = 100L val buttonChangeMore = 1000L fun changeBy(by: Long) { - val current = (subtitleOffsetInput.text?.toString()?.toLongOrNull() ?: 0) + by - subtitleOffsetInput.text = - Editable.Factory.getInstance()?.newEditable(current.toString()) + val current = (input.text?.toString()?.toLongOrNull() ?: 0) + by + input.text = Editable.Factory.getInstance()?.newEditable(current.toString()) } - subtitleOffsetAdd.setOnClickListener { + add.setOnClickListener { changeBy(buttonChange) } - subtitleOffsetAddMore.setOnClickListener { + addMore.setOnClickListener { changeBy(buttonChangeMore) } - subtitleOffsetSubtract.setOnClickListener { + sub.setOnClickListener { changeBy(-buttonChange) } - subtitleOffsetSubtractMore.setOnClickListener { + subMore.setOnClickListener { changeBy(-buttonChangeMore) } @@ -500,23 +413,17 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isFullScreenPlayer) activity?.hideSystemUI() } - applyBtt.setOnClickListener { + applyButton.setOnClickListener { dialog.dismissSafe(activity) player.seekTime(1L) } - resetBtt.setOnClickListener { - subtitleDelay = 0 - dialog.dismissSafe(activity) - player.seekTime(1L) - } - cancelBtt.setOnClickListener { + cancelButton.setOnClickListener { subtitleDelay = beforeOffset dialog.dismissSafe(activity) } } } - private fun showSpeedDialog() { val speedsText = listOf( @@ -553,42 +460,39 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } fun resetRewindText() { - playerBinding?.exoRewText?.text = + exo_rew_text?.text = getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) } fun resetFastForwardText() { - playerBinding?.exoFfwdText?.text = + exo_ffwd_text?.text = getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) } private fun rewind() { try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerRewHolder.alpha = 1f + player_center_menu?.isGone = false + player_rew_holder?.alpha = 1f - val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - exoRew.startAnimation(rotateLeft) + val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) + exo_rew?.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?) { - exoRewText.post { - resetRewindText() - playerCenterMenu.isGone = !isShowing - playerRewHolder.alpha = if (isShowing) 1f else 0f - } + override fun onAnimationEnd(animation: Animation?) { + exo_rew_text?.post { + resetRewindText() + player_center_menu?.isGone = !isShowing + player_rew_holder?.alpha = if (isShowing) 1f else 0f } - }) - exoRewText.startAnimation(goLeft) - exoRewText.text = - getString(R.string.rew_text_format).format(fastForwardTime / 1000) - } + } + }) + exo_rew_text?.startAnimation(goLeft) + exo_rew_text?.text = getString(R.string.rew_text_format).format(fastForwardTime / 1000) player.seekTime(-fastForwardTime) } catch (e: Exception) { logError(e) @@ -597,31 +501,28 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun fastForward() { try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerFfwdHolder.alpha = 1f + player_center_menu?.isGone = false + player_ffwd_holder?.alpha = 1f - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - exoFfwd.startAnimation(rotateRight) + val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) + exo_ffwd?.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?) { - exoFfwdText.post { - resetFastForwardText() - playerCenterMenu.isGone = !isShowing - playerFfwdHolder.alpha = if (isShowing) 1f else 0f - } + override fun onAnimationEnd(animation: Animation?) { + exo_ffwd_text?.post { + resetFastForwardText() + player_center_menu?.isGone = !isShowing + player_ffwd_holder?.alpha = if (isShowing) 1f else 0f } - }) - exoFfwdText.startAnimation(goRight) - exoFfwdText.text = - getString(R.string.ffw_text_format).format(fastForwardTime / 1000) - } + } + }) + exo_ffwd_text?.startAnimation(goRight) + exo_ffwd_text?.text = getString(R.string.ffw_text_format).format(fastForwardTime / 1000) player.seekTime(fastForwardTime) } catch (e: Exception) { logError(e) @@ -631,13 +532,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun onClickChange() { isShowing = !isShowing if (isShowing) { - playerBinding?.playerIntroPlay?.isGone = true + player_intro_play?.isGone = true autoHide() } if (isFullScreenPlayer) activity?.hideSystemUI() animateLayoutChanges() - playerBinding?.playerPausePlay?.requestFocus() + player_pause_play?.requestFocus() } private fun toggleLock() { @@ -646,10 +547,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked - updateOrientation(true) // set true to ignore auto rotate to stay in current orientation - if (isLocked && isShowing) { - playerBinding?.playerHolder?.postDelayed({ + player_holder?.postDelayed({ if (isLocked && isShowing) { onClickChange() } @@ -657,36 +556,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 - } - 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) + val fadeAnimation = AlphaAnimation(player_video_title.alpha, fadeTo).apply { + duration = 100 + fillAfter = true } + + 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() } @@ -700,39 +599,34 @@ open class FullScreenPlayer : AbstractPlayerFragment() { togglePlayerTitleGone = true } } - 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_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 // player_video_title_rez?.isGone = 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 - } + 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 } private fun updateLockUI() { - 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)) - } + 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)) } } } @@ -740,17 +634,8 @@ 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 - playerBinding?.playerHolder?.postDelayed({ + player_holder?.postDelayed({ if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { onClickChange() } @@ -762,7 +647,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun toggleShowDelayed() { if (doubleTapEnabled || doubleTapPauseEnabled) { val index = currentDoubleTapIndex - playerBinding?.playerHolder?.postDelayed({ + player_holder?.postDelayed({ if (index == currentDoubleTapIndex) { onClickChange() } @@ -893,270 +778,249 @@ 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() - playerBinding?.apply { - playerIntroPlay.isGone = true + 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) - 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() + 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) + } + } } } } - 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) - } - } - } - } - } - - // 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 + // 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 ) { - if (!isLocked - && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short - ) { - currentClickCount++ + 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 - ) - } + 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() } - } else if (doubleTapEnabled && isFullScreenPlayer) { - if (currentTouch.x < screenWidth / 2) { - rewind() - } else { - fastForward() + 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() + } } - } 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() } - - // 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() + } else { + currentClickCount = 0 } - 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 + // call auto hide as it wont hide when you have your finger down + autoHide() - 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() - } + // reset variables + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null - TouchAction.Brightness - } else { - TouchAction.Volume + // 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() } - } - } - if (swipeHorizontalEnabled) { - if (abs(diffFromStart.x * 100 / screenHeight) > MINIMUM_HORIZONTAL_SWIPE) { - currentTouchAction = TouchAction.Time + + 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() + // 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 + // 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 - playerTimeText.apply { - text = - "${convertTimeToString(newMs / 1000)} [${ - (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) - }${convertTimeToString(abs(skipMs / 1000))}]" - isVisible = true - } - } + 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 -> { + player_progressbar_right_holder?.isVisible = true + val lastRequested = currentRequestedBrightness + currentRequestedBrightness = + min( + 1.0f, + max(currentRequestedBrightness + verticalAddition, 0.0f) + ) - TouchAction.Brightness -> { - playerProgressbarRightHolder.isVisible = true - val lastRequested = currentRequestedBrightness - currentRequestedBrightness = + // 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() + ) + )] + ) + } + 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 = min( 1.0f, - max(currentRequestedBrightness + verticalAddition, 0.0f) + max(currentRequestedVolume + 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 - playerProgressbarRight.max = 100_000 - playerProgressbarRight.progress = - max(2_000, (currentRequestedBrightness * 100_000f).toInt()) + player_progressbar_left?.max = 100_000 + player_progressbar_left?.progress = + max(2_000, (currentRequestedVolume * 100_000f).toInt()) - playerProgressbarRightIcon.setImageResource( - brightnessIcons[min( // clamp the value just in case - brightnessIcons.size - 1, + player_progressbar_left_icon?.setImageResource( + volumeIcons[min( // clamp the value just in case + volumeIcons.size - 1, max( 0, - round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() + round(currentRequestedVolume * (volumeIcons.size - 1)).toInt() ) )] ) - } - 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) + // 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 - // 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() - ) - )] + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + newVolumeAdjusted, + 0 ) - - // 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 } } } @@ -1166,7 +1030,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return true } - @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -1182,30 +1045,26 @@ 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 (playerBinding?.playerPausePlay?.isFocused == true) { + } else if (player_pause_play?.isFocused == true) { player.seekTime(-androidTVInterfaceOnSeekTime) return true } } - KeyEvent.KEYCODE_DPAD_RIGHT -> { if (!isShowing && !isLocked) { player.seekTime(androidTVInterfaceOffSeekTime) return true - } else if (playerBinding?.playerPausePlay?.isFocused == true) { + } else if (player_pause_play?.isFocused == true) { player.seekTime(androidTVInterfaceOnSeekTime) return true } @@ -1232,7 +1091,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // netflix capture back and hide ~monke KeyEvent.KEYCODE_BACK -> { - if (isShowing && isLayout(TV or EMULATOR)) { + if (isShowing && isTv) { onClickChange() return true } @@ -1248,13 +1107,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() { isShowing = false // if nothing has loaded these buttons should not be visible - playerBinding?.apply { - playerSkipEpisode.isVisible = false - playerGoForward.isVisible = false - playerTracksBtt.isVisible = false - playerSkipOp.isVisible = false - shadowOverlay.isVisible = false - } + player_skip_episode?.isVisible = false + player_tracks_btt?.isVisible = false + player_skip_op?.isVisible = false + shadow_overlay?.isVisible = false + updateLockUI() updateUIVisibility() animateLayoutChanges() @@ -1262,20 +1119,11 @@ 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(DataStoreHelper.playBackSpeed) - savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { - subtitleDelay = it - } + setPlayBackSpeed(getKey(PLAYBACK_SPEED_KEY) ?: 1.0f) // handle tv controls playerEventListener = { eventType -> @@ -1283,69 +1131,50 @@ 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() } @@ -1363,6 +1192,15 @@ 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) @@ -1372,16 +1210,10 @@ 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() @@ -1398,14 +1230,6 @@ 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), @@ -1423,231 +1247,129 @@ open class FullScreenPlayer : AbstractPlayerFragment() { false ) - 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 -// ) + currentPrefQuality = settingsManager.getInt( + ctx.getString(R.string.quality_pref_key), + currentPrefQuality + ) // useSystemBrightness = // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) } - playerBinding?.apply { - playerSpeedBtt.isVisible = playBackSpeedEnabled - playerResizeBtt.isVisible = playerResizeEnabled - playerRotateBtt.isVisible = playerRotateEnabled - if (hideControlsNames) { - hideControlsNames() - } - } + + player_speed_btt?.isVisible = playBackSpeedEnabled + player_resize_btt?.isVisible = playerResizeEnabled } catch (e: Exception) { logError(e) } - playerBinding?.apply { - - 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 - } - } - } - - 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 - } + player_pause_play?.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.PlayPauseToggle) } - // cs3 is peak media center - setRemainingTimeCounter(durationMode || isLayout(TV)) - playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> - updateRemainingTime() + + 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() + } + } + return@setOnTouchListener false } // 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) } } - - @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 - } - } -} +} \ No newline at end of file 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 d4fd047c..67f58195 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,7 +6,6 @@ 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 @@ -14,46 +13,29 @@ import android.view.View import android.view.ViewGroup import android.widget.* import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.OptIn +import androidx.appcompat.app.AlertDialog 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 androidx.media3.common.Format.NO_VALUE -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.UnstableApi +import com.google.android.exoplayer2.Format.NO_VALUE +import com.google.android.exoplayer2.util.MimeTypes +import com.hippo.unifile.UniFile 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.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.settings.SettingsFragment.Companion.isTvSettings 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 @@ -64,11 +46,18 @@ 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 com.lagradost.safefile.SafeFile +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 kotlinx.coroutines.Job -import java.io.Serializable import java.util.* -import kotlin.math.abs +import kotlin.collections.ArrayList +import kotlin.collections.HashMap class GeneratorPlayer : FullScreenPlayer() { companion object { @@ -82,10 +71,7 @@ class GeneratorPlayer : FullScreenPlayer() { } val subsProviders - get() = subtitleProviders.filter { provider -> - (provider as? AbstractSubApi)?.let { !it.requiresLogin || it.loginInfo() != null } - ?: true - } + get() = subtitleProviders.filter { !it.requiresLogin || it.loginInfo() != null } val subsProvidersIsActive get() = subsProviders.isNotEmpty() } @@ -108,43 +94,18 @@ 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 - binding?.overlayLoadingSkipButton?.isVisible = false - binding?.playerLoadingOverlay?.isVisible = true + overlay_loading_skip_button?.isVisible = false + player_loading_overlay?.isVisible = true } - 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) + private fun setSubtitles(sub: SubtitleData?): Boolean { + currentSelectedSubtitles = sub + //Log.i(TAG, "setSubtitles = $sub") + return player.setPreferredSubtitles(sub) } override fun embeddedSubtitlesFetched(subtitles: List) { @@ -153,7 +114,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onTracksInfoChanged() { val tracks = player.getVideoTracks() - playerBinding?.playerTracksBtt?.isVisible = + player_tracks_btt?.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! @@ -162,13 +123,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerStatusChanged() { - super.playerStatusChanged() - if (player.getIsPlaying()) { - viewModel.forceClearCache = false - } - } - private fun noSubtitles(): Boolean { return setSubtitles(null) } @@ -182,7 +136,7 @@ class GeneratorPlayer : FullScreenPlayer() { return durPos.position } - private var currentVerifyLink: Job? = null + var currentVerifyLink: Job? = null private fun loadExtractorJob(extractorLink: ExtractorLink?) { currentVerifyLink?.cancel() @@ -200,12 +154,12 @@ class GeneratorPlayer : FullScreenPlayer() { if (link == null) return // manage UI - binding?.playerLoadingOverlay?.isVisible = false + player_loading_overlay?.isVisible = false uiReset() currentSelectedLink = link currentMeta = viewModel.getMeta() nextMeta = viewModel.getNextMeta() - // setEpisodes(viewModel.getAllMeta() ?: emptyList()) + setEpisodes(viewModel.getAllMeta() ?: emptyList()) isActive = true setPlayerDimen(null) setTitle() @@ -228,7 +182,6 @@ class GeneratorPlayer : FullScreenPlayer() { (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( currentSubs, settings = true, downloads = true ), - preview = isFullScreenPlayer ) } @@ -236,31 +189,17 @@ class GeneratorPlayer : FullScreenPlayer() { player.addTimeStamps(listOf()) // clear stamps } - private fun closestQuality(target: Int?): Qualities { - if (target == null) return Qualities.Unknown - return Qualities.entries.minBy { abs(it.value - target) } - } - - 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> { + private fun sortLinks(useQualitySettings: Boolean = true): List> { return currentLinks.sortedBy { - -getLinkPriority(qualityProfile, it) + val (linkData, _) = it + var quality = linkData?.quality ?: Qualities.Unknown.value + + // 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) } } @@ -268,7 +207,6 @@ class GeneratorPlayer : FullScreenPlayer() { var episode: Int? = null, var season: Int? = null, var name: String? = null, - var imdbId: String? = null, ) private fun getMetaData(): TempMetaData { @@ -282,7 +220,6 @@ class GeneratorPlayer : FullScreenPlayer() { } meta.name = newMeta.headerName } - is ExtractorUri -> { if (newMeta.tvType?.isMovieType() == false) { meta.episode = newMeta.episode @@ -295,15 +232,13 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun openOnlineSubPicker( - context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit) + context: Context, imdbId: Long?, dismissCallback: (() -> Unit) ) { val providers = subsProviders val isSingleProvider = subsProviders.size == 1 val dialog = Dialog(context, R.style.AlertDialogCustomBlack) - val binding = - DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false) - dialog.setContentView(binding.root) + dialog.setContentView(R.layout.dialog_online_subtitles) var currentSubtitles: List = emptyList() var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null @@ -341,7 +276,6 @@ 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) @@ -365,14 +299,16 @@ class GeneratorPlayer : FullScreenPlayer() { } dialog.show() - binding.cancelBtt.setOnClickListener { + dialog.cancel_btt.setOnClickListener { dialog.dismissSafe() } - binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE - binding.subtitleAdapter.adapter = arrayAdapter + dialog.subtitle_adapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE + dialog.subtitle_adapter.adapter = arrayAdapter + val adapter = + dialog.subtitle_adapter.adapter as? ArrayAdapter - binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ -> + dialog.subtitle_adapter.setOnItemClickListener { _, _, position, _ -> currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } @@ -381,24 +317,23 @@ class GeneratorPlayer : FullScreenPlayer() { fun setSubtitlesList(list: List) { currentSubtitles = list - arrayAdapter.clear() - arrayAdapter.addAll(currentSubtitles) + adapter?.clear() + adapter?.addAll(currentSubtitles) } val currentTempMeta = getMetaData() - // bruh idk why it is not correct val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent)) - binding.searchLoadingBar.progressTintList = color - binding.searchLoadingBar.indeterminateTintList = color + dialog.search_loading_bar.progressTintList = color + dialog.search_loading_bar.indeterminateTintList = color observeNullable(viewModel.currentSubtitleYear) { // When year is changed search again - binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) - binding.yearBtt.text = it?.toString() ?: txt(R.string.none).asString(context) + dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) + dialog.year_btt.text = it?.toString() ?: txt(R.string.none).asString(context) } - binding.yearBtt.setOnClickListener { + dialog.year_btt?.setOnClickListener { val none = txt(R.string.none).asString(context) val currentYear = Calendar.getInstance().get(Calendar.YEAR) val earliestYear = 1900 @@ -426,18 +361,15 @@ class GeneratorPlayer : FullScreenPlayer() { ) } - binding.subtitlesSearch.setOnQueryTextListener(object : + dialog.subtitles_search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { - binding.searchLoadingBar.show() + dialog.search_loading_bar?.show() ioSafe { val search = AbstractSubtitleEntities.SubtitleSearch( query = query ?: return@ioSafe, - imdbId = loadResponse?.getImdbId(), - tmdbId = loadResponse?.getTMDbId()?.toInt(), - malId = loadResponse?.getMalId()?.toInt(), - aniListId = loadResponse?.getAniListId()?.toInt(), + imdb = imdbId, epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, lang = currentLanguageTwoLetters.ifBlank { null }, @@ -464,7 +396,7 @@ class GeneratorPlayer : FullScreenPlayer() { // ugly ik activity?.runOnUiThread { setSubtitlesList(items) - binding.searchLoadingBar.hide() + dialog.search_loading_bar?.hide() } } @@ -476,7 +408,7 @@ class GeneratorPlayer : FullScreenPlayer() { } }) - binding.searchFilter.setOnClickListener { view -> + dialog.search_filter.setOnClickListener { view -> val lang639_1 = languages.map { it.ISO_639_1 } activity?.showDialog(languages.map { it.languageName }, lang639_1.indexOf(currentLanguageTwoLetters), @@ -485,29 +417,24 @@ class GeneratorPlayer : FullScreenPlayer() { true, { }) { index -> currentLanguageTwoLetters = lang639_1[index] - binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) + dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) } } - binding.applyBtt.setOnClickListener { + dialog.apply_btt.setOnClickListener { currentSubtitle?.let { currentSubtitle -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> ioSafe { - 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()) - } + 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) } } } @@ -520,11 +447,11 @@ class GeneratorPlayer : FullScreenPlayer() { } dialog.show() - binding.subtitlesSearch.setQuery(currentTempMeta.name, true) + dialog.subtitles_search.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( @@ -545,11 +472,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun addAndSelectSubtitles( - vararg subtitleData: SubtitleData - ) { - if (subtitleData.isEmpty()) return - val selectedSubtitle = subtitleData.first() + private fun addAndSelectSubtitles(subtitleData: SubtitleData) { val ctx = context ?: return val subs = currentSubs + subtitleData @@ -561,13 +484,14 @@ class GeneratorPlayer : FullScreenPlayer() { player.saveData() player.reloadPlayer(ctx) - setSubtitles(selectedSubtitle) - viewModel.addSubtitles(subtitleData.toSet()) + setSubtitles(subtitleData) + viewModel.addSubtitles(setOf(subtitleData)) selectSourceDialog?.dismissSafe() showToast( - String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), + activity, + String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name), Toast.LENGTH_LONG ) } @@ -580,24 +504,22 @@ class GeneratorPlayer : FullScreenPlayer() { if (uri == null) return@normalSafeApiCall val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall // RW perms for the path - ctx.contentResolver.takePersistableUriPermission( - uri, + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - val file = SafeFile.fromUri(ctx, uri) - val fileName = file?.name() - println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName") + ctx.contentResolver.takePersistableUriPermission(uri, flags) + + val file = UniFile.fromUri(ctx, uri) + println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}") // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES - val name = fileName ?: uri.toString() + val name = file.name ?: uri.toString() val subtitleData = SubtitleData( name, uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), - emptyMap(), - null + emptyMap() ) addAndSelectSubtitles(subtitleData) @@ -613,19 +535,17 @@ class GeneratorPlayer : FullScreenPlayer() { //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val isPlaying = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + player.handleEvent(CSPlayerEvent.Pause) val currentSubtitles = sortSubs(currentSubs) val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) - val binding = - PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) - sourceDialog.setContentView(binding.root) + sourceDialog.setContentView(R.layout.player_select_source_and_subs) selectSourceDialog = sourceDialog sourceDialog.show() - val providerList = binding.sortProviders - val subtitleList = binding.sortSubtitles + val providerList = sourceDialog.sort_providers + val subtitleList = sourceDialog.sort_subtitles val loadFromFileFooter: TextView = layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView @@ -646,8 +566,6 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { - val currentLoadResponse = viewModel.getLoadResponse() - val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView @@ -658,7 +576,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) - openOnlineSubPicker(it.context, currentLoadResponse) { + openOnlineSubPicker(it.context, null) { dismiss() } } @@ -667,39 +585,33 @@ class GeneratorPlayer : FullScreenPlayer() { var sourceIndex = 0 var startSource = 0 - var sortedUrls = emptyList>() - 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 sortedUrls = sortLinks(useQualitySettings = false) + 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 @@ -735,34 +647,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - binding.cancelBtt.setOnClickListener { + sourceDialog.cancel_btt?.setOnClickListener { sourceDialog.dismissSafe(activity) } - 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 { + sourceDialog.subtitles_encoding_format?.apply { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -775,7 +664,7 @@ class GeneratorPlayer : FullScreenPlayer() { text = prefNames[if (index == -1) 0 else index] } - binding.subtitlesClickSettings.setOnClickListener { + sourceDialog.subtitles_click_settings?.setOnClickListener { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -797,13 +686,14 @@ 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 } } - binding.applyBtt.setOnClickListener { + sourceDialog.apply_btt?.setOnClickListener { var init = false if (sourceIndex != startSource) { init = true @@ -843,19 +733,20 @@ class GeneratorPlayer : FullScreenPlayer() { it.height?.times(-1) } val currentAudioTracks = tracks.allAudioTracks - val binding: PlayerSelectTracksBinding = - PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) - val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) - trackDialog.setContentView(binding.root) - trackDialog.show() + + val trackBuilder = AlertDialog.Builder(ctx, R.style.AlertDialogCustomBlack) + .setView(R.layout.player_select_tracks) + + val tracksDialog = trackBuilder.create() // selectTracksDialog = tracksDialog - val videosList = binding.videoTracksList - val audioList = binding.autoTracksList + tracksDialog.show() + val videosList = tracksDialog.video_tracks_list + val audioList = tracksDialog.auto_tracks_list - binding.videoTracksHolder.isVisible = currentVideoTracks.size > 1 - binding.audioTracksHolder.isVisible = currentAudioTracks.size > 1 + tracksDialog.video_tracks_holder.isVisible = currentVideoTracks.size > 1 + tracksDialog.audio_tracks_holder.isVisible = currentAudioTracks.size > 1 fun dismiss() { if (isPlaying) { @@ -890,7 +781,7 @@ class GeneratorPlayer : FullScreenPlayer() { videosList.setItemChecked(which, true) } - trackDialog.setOnDismissListener { + tracksDialog.setOnDismissListener { dismiss() // selectTracksDialog = null } @@ -920,11 +811,11 @@ class GeneratorPlayer : FullScreenPlayer() { audioList.setItemChecked(which, true) } - binding.cancelBtt.setOnClickListener { - trackDialog.dismissSafe(activity) + tracksDialog.cancel_btt?.setOnClickListener { + tracksDialog.dismissSafe(activity) } - binding.applyBtt.setOnClickListener { + tracksDialog.apply_btt?.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( currentTrack?.language, currentTrack?.id @@ -937,7 +828,7 @@ class GeneratorPlayer : FullScreenPlayer() { player.setMaxVideoSize(width, height, currentVideo?.id) } - trackDialog.dismissSafe(activity) + tracksDialog.dismissSafe(activity) } } } catch (e: Exception) { @@ -946,25 +837,20 @@ class GeneratorPlayer : FullScreenPlayer() { } - override fun playerError(exception: Throwable) { + override fun playerError(exception: Exception) { Log.i(TAG, "playerError = $currentSelectedLink") - if (!hasNextMirror()) { - viewModel.forceClearCache = true - } super.playerError(exception) } private fun noLinksFound() { - viewModel.forceClearCache = true - - showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) + showToast(activity, 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(currentQualityProfile) + val links = sortLinks() if (links.isEmpty()) { noLinksFound() return @@ -985,12 +871,12 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun hasNextMirror(): Boolean { - val links = sortLinks(currentQualityProfile) + val links = sortLinks() return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = sortLinks(currentQualityProfile) + val links = sortLinks() if (links.isEmpty()) { noLinksFound() return @@ -1013,13 +899,14 @@ class GeneratorPlayer : FullScreenPlayer() { var maxEpisodeSet: Int? = null var hasRequestedStamps: Boolean = false - override fun playerPositionChanged(position: Long, duration: Long) { + override fun playerPositionChanged(posDur: Pair) { // 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 @@ -1048,7 +935,6 @@ class GeneratorPlayer : FullScreenPlayer() { is ResultEpisode -> { DataStoreHelper.removeLastWatched(newMeta.parentId) } - is ExtractorUri -> { DataStoreHelper.removeLastWatched(newMeta.parentId) } @@ -1065,7 +951,6 @@ class GeneratorPlayer : FullScreenPlayer() { isFromDownload = false ) } - is ExtractorUri -> { DataStoreHelper.setLastWatched( resumeMeta.parentId, @@ -1090,24 +975,15 @@ class GeneratorPlayer : FullScreenPlayer() { ctx.getString(R.string.episode_sync_enabled_key), true ) ) maxEpisodeSet = meta.episode - sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) + sync.modifyMaxEpisode(meta.episode) } } if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE } } - - playerBinding?.playerSkipOp?.isVisible = isOpVisible - - when { - isLayout(PHONE) -> - playerBinding?.playerSkipEpisode?.isVisible = - !isOpVisible && viewModel.hasNextEpisode() == true - - else -> - playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true - } + player_skip_op?.isVisible = isOpVisible + player_skip_episode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { viewModel.preLoadNextLinks() @@ -1206,7 +1082,6 @@ class GeneratorPlayer : FullScreenPlayer() { season = meta.season tvType = meta.tvType } - is ExtractorUri -> { headerName = meta.headerName subName = meta.name @@ -1244,7 +1119,7 @@ class GeneratorPlayer : FullScreenPlayer() { //Hide title, if set in setting if (limitTitle < 0) { - playerBinding?.playerVideoTitle?.visibility = View.GONE + player_video_title?.visibility = View.GONE } else { //Truncate video title if it exceeds limit val differenceInLength = playerVideoTitle.length - limitTitle @@ -1255,43 +1130,40 @@ class GeneratorPlayer : FullScreenPlayer() { } val isFiller: Boolean? = (currentMeta as? ResultEpisode)?.isFiller - playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false - playerBinding?.playerVideoTitle?.text = playerVideoTitle + player_episode_filler_holder?.isVisible = isFiller ?: false + player_video_title?.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 -> "" } - playerBinding?.playerVideoTitleRez?.apply { - text = title - isVisible = title.isNotBlank() - } + player_video_title_rez?.text = title + player_video_title_rez?.isVisible = title.isNotBlank() } - override fun playerDimensionsLoaded(width: Int, height: Int) { - super.playerDimensionsLoaded(width, height) - setPlayerDimen(width to height) + override fun playerDimensionsLoaded(widthHeight: Pair) { + setPlayerDimen(widthHeight) } private fun unwrapBundle(savedInstanceState: Bundle?) { Log.i(TAG, "unwrapBundle = $savedInstanceState") savedInstanceState?.let { bundle -> - sync.addSyncs(bundle.getSafeSerializable>("syncData")) + sync.addSyncs(bundle.getSerializable("syncData") as? HashMap?) } } @@ -1299,8 +1171,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 - layout = - if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player + isTv = isTvSettings() + layout = if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java] @@ -1309,14 +1181,7 @@ class GeneratorPlayer : FullScreenPlayer() { unwrapBundle(savedInstanceState) unwrapBundle(arguments) - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - binding = FragmentPlayerBinding.bind(root) - return root - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() + return super.onCreateView(inflater, container, savedInstanceState) } var timestampShowState = false @@ -1327,8 +1192,9 @@ class GeneratorPlayer : FullScreenPlayer() { private fun displayTimeStamp(show: Boolean) { if (timestampShowState == show) return skipIndex++ + println("displayTimeStamp = $show") timestampShowState = show - playerBinding?.skipChapterButton?.apply { + skip_chapter_button?.apply { val showWidth = 170.toPx val noShowWidth = 10.toPx //if((show && width == showWidth) || (!show && width == noShowWidth)) { @@ -1348,18 +1214,7 @@ class GeneratorPlayer : FullScreenPlayer() { from, to ).apply { addListener(onEnd = { - 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() - } - } + if (!show) skip_chapter_button?.isVisible = false }) addUpdateListener { valueAnimator -> val value = valueAnimator.animatedValue as Int @@ -1379,10 +1234,10 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { if (timestamp != null) { - playerBinding?.skipChapterButton?.setText(timestamp.uiText) + skip_chapter_button.setText(timestamp.uiText) displayTimeStamp(true) val currentIndex = skipIndex - playerBinding?.skipChapterButton?.handler?.postDelayed({ + skip_chapter_button?.handler?.postDelayed({ if (skipIndex == currentIndex) displayTimeStamp(false) }, 6000) @@ -1425,12 +1280,11 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadLinks() } - binding?.overlayLoadingSkipButton?.setOnClickListener { + overlay_loading_skip_button?.setOnClickListener { startPlayer() } - binding?.playerLoadingGoBack?.setOnClickListener { - exitFullscreen() + player_loading_go_back?.setOnClickListener { player.release() activity?.popCurrentPage() } @@ -1444,7 +1298,6 @@ class GeneratorPlayer : FullScreenPlayer() { is Resource.Loading -> { startLoading() } - is Resource.Success -> { // provider returned false //if (it.value != true) { @@ -1452,9 +1305,8 @@ class GeneratorPlayer : FullScreenPlayer() { //} startPlayer() } - is Resource.Failure -> { - showToast(it.errorString, Toast.LENGTH_LONG) + showToast(activity, it.errorString, Toast.LENGTH_LONG) startPlayer() } } @@ -1462,22 +1314,11 @@ class GeneratorPlayer : FullScreenPlayer() { observe(viewModel.currentLinks) { currentLinks = it - 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() - } - } - + val turnVisible = it.isNotEmpty() + val wasGone = overlay_loading_skip_button?.isGone == true + overlay_loading_skip_button?.isVisible = turnVisible if (turnVisible && wasGone) { - binding?.overlayLoadingSkipButton?.requestFocus() + overlay_loading_skip_button?.requestFocus() } } @@ -1507,7 +1348,4 @@ class GeneratorPlayer : FullScreenPlayer() { } } } -} - -@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 +} \ 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 6b8e6ea8..a1287e6a 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,51 +1,10 @@ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink -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 - ) - } -} +import com.lagradost.cloudstream3.utils.ExtractorUri interface IGenerator { val hasCache: Boolean - val canSkipLoading: Boolean fun hasNext(): Boolean fun hasPrev(): Boolean @@ -54,15 +13,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, - type: LoadType, + isCasting: Boolean, 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 89c6f73b..ba5a4a85 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,7 +25,6 @@ enum class PlayerEventType(val value: Int) { Resize(13), SearchSubtitlesOnline(14), SkipOp(15), - Restart(16), } enum class CSPlayerEvent(val value: Int) { @@ -39,127 +38,15 @@ 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 { /** @@ -199,8 +86,17 @@ 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 { @@ -211,16 +107,27 @@ interface IPlayer { fun getDuration(): Long? fun getPosition(): Long? - fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI) - fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI) + fun seekTime(time: Long) + fun seekTo(time: Long) fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms fun initCallbacks( - eventHandler: ((PlayerEvent) -> Unit), - /** this is used to request when the player should report back view percentage */ - requestedListeningPercentages: List? = null, + 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) ) fun releaseCallbacks() @@ -238,20 +145,16 @@ interface IPlayer { startPosition: Long? = null, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean? = true, - preview : Boolean = true, + autoPlay: 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, source: PlayerEventSource = PlayerEventSource.UI) + fun handleEvent(event: CSPlayerEvent) fun onStop() fun onPause() @@ -264,19 +167,6 @@ 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 20feae41..1f242481 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,45 +1,17 @@ package com.lagradost.cloudstream3.ui.player -import android.net.Uri -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap -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 +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.utils.* +import java.net.URI -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 @@ -69,13 +41,13 @@ class LinkGenerator( override suspend fun generateLinks( clearCache: Boolean, - type: LoadType, + isCasting: Boolean, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int ): Boolean { links.amap { link -> - if (!extract || !loadExtractor(link.url, referer, { + if (!extract || !loadExtractor(link, referer, { subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it)) }) { callback(it to null) @@ -85,11 +57,12 @@ class LinkGenerator( callback( ExtractorLink( "", - link.name ?: link.url, - unshortenLinkSafe(link.url), // unshorten because it might be a raw link + link, + unshortenLinkSafe(link), // unshorten because it might be a raw link referer ?: "", - Qualities.Unknown.value, - type = INFER_TYPE, + Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall { + URI(link).path?.substringAfterLast(".")?.contains("m3u") + } ?: false ) 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 232440cc..3b47b27a 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 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 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 java.lang.annotation.ElementType.TYPE_USE; import android.os.Handler; @@ -27,28 +27,26 @@ import android.os.Looper; import android.os.Message; import androidx.annotation.IntDef; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; -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 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 java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -67,7 +65,6 @@ 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"; @@ -75,7 +72,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 todo description + * @param outputHandler */ public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) { super(trackType); @@ -167,14 +164,13 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { finalStreamEndPositionUs = C.TIME_UNSET; } - @NonNull @Override public String getName() { return TAG; } @Override - public @Capabilities int supportsFormat(@NonNull Format format) { + public @Capabilities int supportsFormat(Format format) { if (decoderFactory.supportsFormat(format)) { return RendererCapabilities.create( format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM); @@ -314,7 +310,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { return; } // Try and read the next subtitle from the source. - @SampleStream.ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0); + @ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0); if (result == C.RESULT_BUFFER_READ) { if (nextInputBuffer.isEndOfStream()) { inputStreamEnded = true; @@ -417,11 +413,13 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { @SuppressWarnings("unchecked") @Override public boolean handleMessage(Message msg) { - if (msg.what == MSG_UPDATE_OUTPUT) { - invokeUpdateOutputInternal((List) msg.obj); - return true; + switch (msg.what) { + case MSG_UPDATE_OUTPUT: + invokeUpdateOutputInternal((List) msg.obj); + return true; + default: + throw new IllegalStateException(); } - throw new IllegalStateException(); } private void invokeUpdateOutputInternal(List cues) { @@ -440,6 +438,7 @@ 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 deleted file mode 100644 index f00f8a61..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt +++ /dev/null @@ -1,43 +0,0 @@ -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 new file mode 100644 index 00000000..cfe27a30 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt @@ -0,0 +1,158 @@ +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 122eaa97..7faf0cf5 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,22 +5,20 @@ 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 { - const val TAG = "PlayViewGen" + val TAG = "PlayViewGen" } private var generator: IGenerator? = null @@ -40,13 +38,6 @@ 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) } @@ -81,39 +72,22 @@ class PlayerGeneratorViewModel : ViewModel() { } fun preLoadNextLinks() { - val id = getId() - // Do not preload if already loading - if (id == currentLoadingEpisodeId) return - Log.i(TAG, "preLoadNextLinks") currentJob?.cancel() - 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 + currentJob = viewModelScope.launchSafe { + if (generator?.hasCache == true && generator?.hasNext() == true) { + safeApiCall { + generator?.generateLinks( + clearCache = false, + isCasting = false, + {}, + {}, + offset = 1 + ) } } } } - fun getLoadResponse(): LoadResponse? { - return normalSafeApiCall { (generator as? RepoLinkGenerator?)?.page } - } fun getMeta(): Any? { return normalSafeApiCall { generator?.getCurrent() } @@ -173,7 +147,7 @@ class PlayerGeneratorViewModel : ViewModel() { } } - fun loadLinks(type: LoadType = LoadType.InApp) { + fun loadLinks(clearCache: Boolean = false, isCasting: Boolean = false) { Log.i(TAG, "loadLinks") currentJob?.cancel() @@ -182,24 +156,18 @@ class PlayerGeneratorViewModel : ViewModel() { val currentSubs = mutableSetOf() // clear old data - _currentSubs.postValue(emptySet()) - _currentLinks.postValue(emptySet()) + _currentSubs.postValue(currentSubs) + _currentLinks.postValue(currentLinks) // load more data _loadingLinks.postValue(Resource.Loading()) val loadingState = safeApiCall { - generator?.generateLinks(type = type, clearCache = forceClearCache, callback = { + generator?.generateLinks(clearCache = clearCache, isCasting = isCasting, { currentLinks.add(it) - // Clone to prevent ConcurrentModificationException - normalSafeApiCall { - // Extra normalSafeApiCall since .toSet() iterates. - _currentLinks.postValue(currentLinks.toSet()) - } - }, subtitleCallback = { + _currentLinks.postValue(currentLinks) + }, { currentSubs.add(it) - normalSafeApiCall { - _currentSubs.postValue(currentSubs.toSet()) - } + // _currentSubs.postValue(currentSubs) // this causes ConcurrentModificationException, so fuck it }) } 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 93857234..0fbc22f6 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,23 +7,28 @@ 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 PendingIntent.getBroadcast( - activity, - code, - Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), - PendingIntent.FLAG_IMMUTABLE - ) + 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 + ) + } } @RequiresApi(Build.VERSION_CODES.O) @@ -43,7 +48,7 @@ class PlayerPipHelper { } @RequiresApi(Build.VERSION_CODES.O) - fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) { + fun updatePIPModeActions(activity: Activity, isPlaying: Boolean) { val actions: ArrayList = ArrayList() actions.add( getRemoteAction( @@ -82,32 +87,9 @@ class PlayerPipHelper { CSPlayerEvent.SeekForward ) ) - - // 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() - ) - } + activity.setPictureInPictureParams( + PictureInPictureParams.Builder().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 02a7ee03..8d85f176 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,10 +4,8 @@ import android.util.Log import android.util.TypedValue import android.view.ViewGroup import android.widget.FrameLayout -import androidx.annotation.OptIn -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.SubtitleView +import com.google.android.exoplayer2.ui.SubtitleView +import com.google.android.exoplayer2.util.MimeTypes import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveBloat import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveCaptions @@ -32,15 +30,13 @@ 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 languageCode: String? + val headers: Map ) { /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { @@ -49,7 +45,6 @@ data class SubtitleData( } } -@OptIn(UnstableApi::class) class PlayerSubtitleHelper { private var activeSubtitles: Set = emptySet() private var allSubtitles: Set = emptySet() @@ -85,8 +80,7 @@ class PlayerSubtitleHelper { url = subtitleFile.url, origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), - headers = emptyMap(), - languageCode = subtitleFile.lang + headers = emptyMap() ) } } 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 deleted file mode 100644 index ae800dbd..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt +++ /dev/null @@ -1,547 +0,0 @@ -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 588afbb5..2ce53ea5 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,22 +1,15 @@ 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, @@ -24,12 +17,11 @@ class RepoLinkGenerator( ) : IGenerator { companion object { const val TAG = "RepoLink" - val cache: HashMap, Cache> = + val cache: HashMap, Pair, MutableSet>> = hashMapOf() } override val hasCache = true - override val canSkipLoading = true override fun hasNext(): Boolean { return currentIndex < episodes.size - 1 @@ -75,19 +67,18 @@ class RepoLinkGenerator( override suspend fun generateLinks( clearCache: Boolean, - type: LoadType, + isCasting: Boolean, 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, lastCachedTimestamp) = if (clearCache) { - Cache(mutableSetOf(), mutableSetOf(), unixTime) + val (currentLinkCache, currentSubsCache) = if (clearCache) { + Pair(mutableSetOf(), mutableSetOf()) } else { - cache[current.apiName to current.id] ?: Cache(mutableSetOf(), mutableSetOf(), unixTime) + cache[Pair(current.apiName, current.id)] ?: Pair(mutableSetOf(), mutableSetOf()) } //val currentLinkCache = if (clearCache) mutableSetOf() else linkCache[index].toMutableSet() @@ -97,15 +88,9 @@ class RepoLinkGenerator( val currentSubsUrls = mutableSetOf() // makes all subs urls unique val currentSubsNames = mutableSetOf() // makes all subs names unique - val invalidateCache = unixTime - lastCachedTimestamp > 60 * 20 // 20 minutes - if(invalidateCache){ - currentLinkCache.clear() - currentSubsCache.clear() - } - - currentLinkCache.filter { allowedTypes.contains(it.type) }.forEach { link -> + currentLinkCache.forEach { link -> currentLinks.add(link.url) - callback(link to null) + callback(Pair(link, null)) } currentSubsCache.forEach { sub -> @@ -123,10 +108,10 @@ class RepoLinkGenerator( val result = APIRepository( getApiFromNameNull(current.apiName) ?: throw Exception("This provider does not exist") ).loadLinks(current.data, - isCasting = LoadType.Chromecast == type, - subtitleCallback = { file -> + isCasting, + { file -> val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (correctFile.url.isNotEmpty() && !currentSubsUrls.contains(correctFile.url)) { + if (!currentSubsUrls.contains(correctFile.url)) { currentSubsUrls.add(correctFile.url) // this part makes sure that all names are unique for UX @@ -147,21 +132,19 @@ class RepoLinkGenerator( } } }, - callback = { link -> + { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (link.url.isNotEmpty() && !currentLinks.contains(link.url) && !currentLinkCache.contains(link)) { - currentLinks.add(link.url) - - if (allowedTypes.contains(link.type)) { + if (!currentLinks.contains(link.url)) { + if (!currentLinkCache.contains(link)) { + currentLinks.add(link.url) callback(Pair(link, null)) + currentLinkCache.add(link) + //linkCache[index] = currentLinkCache } - - currentLinkCache.add(link) - // linkCache[index] = currentLinkCache } } ) - cache[Pair(current.apiName, current.id)] = Cache(currentLinkCache, currentSubsCache, unixTime) + cache[Pair(current.apiName, current.id)] = Pair(currentLinkCache, currentSubsCache) 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 deleted file mode 100644 index ce457740..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 45f6aa66..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index 3267efd7..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 0537092c..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index bc6282af..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt +++ /dev/null @@ -1,100 +0,0 @@ -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 12adc040..ba57d2de 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,7 +4,6 @@ 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 @@ -17,11 +16,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 @@ -32,18 +31,13 @@ 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.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.AppUtils.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() { @@ -51,13 +45,6 @@ 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, @@ -85,8 +72,6 @@ class QuickSearchFragment : Fragment() { private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel - var binding: QuickSearchBinding? = null - private var bottomSheetDialog: BottomSheetDialog? = null @@ -94,21 +79,13 @@ 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() - 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() + return inflater.inflate(R.layout.quick_search, container, false) } override fun onDestroy() { @@ -134,7 +111,7 @@ class QuickSearchFragment : Fragment() { activity?.getSpanCount()?.let { HomeFragment.currentSpan = it } - binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan + quick_search_autofit_results.spanCount = HomeFragment.currentSpan HomeFragment.currentSpan = HomeFragment.currentSpan HomeFragment.configEvent.invoke(HomeFragment.currentSpan) } @@ -146,7 +123,7 @@ class QuickSearchFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.quickSearchRoot) + context?.fixPaddingStatusbar(quick_search_root) fixGrid() arguments?.getStringArray(PROVIDER_KEY)?.let { @@ -159,25 +136,23 @@ class QuickSearchFragment : Fragment() { } else false if (isSingleProvider) { - binding?.quickSearchAutofitResults?.apply { - adapter = SearchAdapter( + quick_search_autofit_results.adapter = activity?.let { + SearchAdapter( ArrayList(), - this, + quick_search_autofit_results, ) { callback -> - SearchHelper.handleSearchClickCallback(callback) + SearchHelper.handleSearchClickCallback(activity, callback) } } - try { - binding?.quickSearch?.queryHint = - getString(R.string.search_hint_site).format(providers?.first()) + quick_search?.queryHint = getString(R.string.search_hint_site).format(providers?.first()) } catch (e: Exception) { logError(e) } } else { - binding?.quickSearchMasterRecycler?.adapter = - ParentItemAdapter(fragment = this, id = "quickSearchMasterRecycler".hashCode(), { callback -> - SearchHelper.handleSearchClickCallback(callback) + quick_search_master_recycler?.adapter = + ParentItemAdapter(mutableListOf(), { callback -> + SearchHelper.handleSearchClickCallback(activity, callback) //when (callback.action) { //SEARCH_ACTION_LOAD -> { // clickCallback?.invoke(callback) @@ -189,17 +164,18 @@ class QuickSearchFragment : Fragment() { bottomSheetDialog = null }) }) - binding?.quickSearchMasterRecycler?.layoutManager = GridLayoutManager(context, 1) + quick_search_master_recycler?.layoutManager = GridLayoutManager(context, 1) } - binding?.quickSearchAutofitResults?.isVisible = isSingleProvider - binding?.quickSearchMasterRecycler?.isGone = isSingleProvider + + quick_search_autofit_results?.isVisible = isSingleProvider + quick_search_master_recycler?.isGone = isSingleProvider val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding?.quickSearchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { + (quick_search_master_recycler?.adapter as ParentItemAdapter?)?.apply { updateList(list.map { ongoing -> val ongoingList = HomePageList( ongoing.apiName, @@ -216,24 +192,19 @@ class QuickSearchFragment : Fragment() { } val searchExitIcon = - binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + quick_search?.findViewById(androidx.appcompat.R.id.search_close_btn) //val searchMagIcon = - // binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) + // quick_search?.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() - activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) - searchExitIcon?.setColorFilter(searchExitIconColor.data) - - binding?.quickSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + quick_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - UIHelper.hideKeyboard(binding?.quickSearch) + UIHelper.hideKeyboard(quick_search) return true } @@ -243,28 +214,27 @@ class QuickSearchFragment : Fragment() { return true } }) - binding?.quickSearchLoadingBar?.alpha = 0f + + quick_search_loading_bar.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - (binding?.quickSearchAutofitResults?.adapter as? SearchAdapter)?.updateList( + (quick_search_autofit_results?.adapter as? SearchAdapter)?.updateList( context?.filterSearchResultByFilmQuality(data) ?: data ) } searchExitIcon?.alpha = 1f - binding?.quickSearchLoadingBar?.alpha = 0f + quick_search_loading_bar?.alpha = 0f } - is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding?.quickSearchLoadingBar?.alpha = 0f + quick_search_loading_bar?.alpha = 0f } - is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding?.quickSearchLoadingBar?.alpha = 1f + quick_search_loading_bar?.alpha = 1f } } } @@ -276,21 +246,13 @@ class QuickSearchFragment : Fragment() { // UIHelper.showInputMethod(view.findFocus()) // } //} - if (isLayout(PHONE or EMULATOR)) { - binding?.quickSearchBack?.apply { - isVisible = true - setOnClickListener { - activity?.popCurrentPage() - } - } - } - if (isLayout(TV)) { - binding?.quickSearch?.requestFocus() + quick_search_back.setOnClickListener { + activity?.popCurrentPage() } arguments?.getString(AUTOSEARCH_KEY)?.let { - binding?.quickSearch?.setQuery(it, true) + quick_search?.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 0ca326dd..92cecc37 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,19 +3,18 @@ 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( - private var nextFocusUpId: Int? = null, - private val focusCallback: (View?) -> Unit = {} -) : RecyclerView.Adapter() { +class ActorAdaptor() : RecyclerView.Adapter() { data class ActorMetaData( var isInverted: Boolean, val actor: ActorData, @@ -25,8 +24,7 @@ class ActorAdaptor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return CardViewHolder( - CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - focusCallback + LayoutInflater.from(parent.context).inflate(R.layout.cast_item, parent, false), ) } @@ -68,11 +66,17 @@ class ActorAdaptor( } } - private inner class CardViewHolder( - val binding: CastItemBinding, - private val focusCallback: (View?) -> Unit = {} + private class CardViewHolder + constructor( + itemView: View, ) : - RecyclerView.ViewHolder(binding.root) { + 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 fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { @@ -81,63 +85,43 @@ class ActorAdaptor( 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) } - binding.apply { - actorImage.setImage(mainImg) + 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 } - } ?: actor.roleString?.let { + )?.let { text -> actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false + actorExtra.text = text } + } ?: 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 d12521b3..0932b001 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,32 +3,35 @@ 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.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.google.android.material.button.MaterialButton 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.DOWNLOAD_ACTION_LONG_CLICK +import com.lagradost.cloudstream3.ui.download.DownloadButtonViewHolder import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -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.ui.download.EasyDownloadButton +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.utils.AppUtils.html import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +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.* const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 const val ACTION_PLAY_EPISODE_IN_VLC_PLAYER = 2 @@ -56,9 +59,6 @@ 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,10 +88,49 @@ 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) { @@ -105,62 +144,27 @@ class EpisodeAdapter( diffResult.dispatchUpdatesTo(this) } - 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 + var 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 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() - } + return EpisodeCardViewHolder( + LayoutInflater.from(parent.context) + .inflate(layout, parent, false), + hasDownloadSupport, + clickCallback, + downloadClickCallback + ) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { - is EpisodeCardViewHolderLarge -> { - holder.bind(getItem(position)) - } - - is EpisodeCardViewHolderSmall -> { - holder.bind(getItem(position)) + is EpisodeCardViewHolder -> { + holder.bind(cardList[position]) + mBoundViewHolders.add(holder) } } } @@ -169,148 +173,91 @@ class EpisodeAdapter( return cardList.size } - class EpisodeCardViewHolderLarge( - val binding: ResultEpisodeLargeBinding, + class EpisodeCardViewHolder + constructor( + itemView: View, private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { + ) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { + override var downloadButton = EasyDownloadButton() + + var episodeDownloadBar: ContentLoadingProgressBar? = null + var episodeDownloadImage: ImageView? = null 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 - binding.episodeLinHolder.layoutParams.width = setWidth - binding.episodeHolderLarge.layoutParams.width = setWidth - binding.episodeHolder.layoutParams.width = setWidth + val isTrueTv = isTrueTvSettings() + 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 - 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)) - } + 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 - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) - } + episodeDownloadBar = + parentView.result_episode_progress_downloaded + episodeDownloadImage = parentView.result_episode_download - 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 + } + + 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)) } - 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 - } + episodePoster?.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) + return@setOnLongClickListener true } } @@ -318,7 +265,7 @@ class EpisodeAdapter( clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) } - if (isLayout(TV)) { + if (isTrueTv) { itemView.isFocusable = true itemView.isFocusableInTouchMode = true //itemView.touchscreenBlocksFocus = false @@ -329,91 +276,47 @@ class EpisodeAdapter( return@setOnLongClickListener true } - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + episodeDownloadImage?.isVisible = hasDownloadSupport + episodeDownloadBar?.isVisible = hasDownloadSupport + reattachDownloadButton() } - } - 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 - } + 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 + ) - binding.apply { - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( + downloadButton.setUpButton( + downloadInfo?.fileLength, + downloadInfo?.totalBytes, + episodeDownloadBar ?: return, + episodeDownloadImage ?: return, + null, 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 + card.name, + card.poster, + card.episode, + card.season, + card.id, + card.parentId, + card.rating, + card.description, + System.currentTimeMillis(), + ) ) { - 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) - } + if (it.action == DOWNLOAD_ACTION_DOWNLOAD) { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, 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 eecd6262..ebd6a658 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,17 +1,30 @@ 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.databinding.ResultMiniImageBinding -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +/* +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, @@ -21,9 +34,7 @@ class ImageAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return ImageViewHolder( - //result_mini_image - ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) - // LayoutInflater.from(parent.context).inflate(layout, parent, false) + LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } @@ -54,15 +65,16 @@ class ImageAdapter( diffResult.dispatchUpdatesTo(this) } - class ImageViewHolder(val binding: ResultMiniImageBinding) : - RecyclerView.ViewHolder(binding.root) { + class ImageViewHolder + constructor(itemView: View) : + RecyclerView.ViewHolder(itemView) { fun bind( img: Int, clickCallback: ((Int) -> Unit)?, nextFocusUp: Int?, nextFocusDown: Int?, ) { - binding.root.apply { + (itemView as? ImageView?)?.apply { setImageResource(img) if (nextFocusDown != null) { this.nextFocusDownId = nextFocusDown @@ -71,7 +83,7 @@ class ImageAdapter( this.nextFocusUpId = nextFocusUp } if (clickCallback != null) { - if (isLayout(TV)) { + if (isTrueTvSettings()) { 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 b4e3062b..59a46264 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,45 +4,18 @@ 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 -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 +fun RecyclerView?.setLinearListLayout(isHorizontal: Boolean = true) { + if(this == null) return 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 - } + this.context?.let { LinearListLayout(it).apply { if (isHorizontal) setHorizontal() else setVertical() } } + ?: this.layoutManager } -open class LinearListLayout(context: Context?) : +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 } @@ -51,8 +24,7 @@ open class LinearListLayout(context: Context?) : orientation = VERTICAL } - private fun getCorrectParent(focused: View?): View? { - if (focused == null) return null + private fun getCorrectParent(focused: View): View? { var current: View? = focused val last: ArrayList = arrayListOf(focused) while (current != null && current !is RecyclerView) { @@ -83,86 +55,27 @@ open 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) 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 + if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) return null + if (direction == View.FOCUS_RIGHT) 1 else -1 } else { - 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_RIGHT || direction == View.FOCUS_LEFT) return null if (direction == View.FOCUS_DOWN) 1 else -1 } - 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) + 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) null } } } catch (e: Exception) { logError(e) - return null + 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 3eab0c71..68dd1c0e 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,18 +1,108 @@ 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 androidx.fragment.app.Fragment +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.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.utils.AppContextUtils.getApiDubstatusSettings -import com.lagradost.cloudstream3.utils.DataStoreHelper +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.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.Event +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 const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 @@ -47,11 +137,7 @@ data class ResultEpisode( /** * Conveys if the episode itself is marked as watched **/ - val videoWatchState: VideoWatchState, - /** Sum of all previous season episode counts + episode */ - val totalEpisodeIndex: Int? = null, - val airDate: Long? = null, - val runTime: Int? = null, + val videoWatchState: VideoWatchState ) fun ResultEpisode.getRealPosition(): Long { @@ -86,9 +172,6 @@ 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 @@ -110,10 +193,7 @@ fun buildResultEpisode( isFiller, tvType, parentId, - videoWatchState, - totalEpisodeIndex, - airDate, - runTime, + videoWatchState ) } @@ -122,113 +202,286 @@ fun ResultEpisode.getWatchProgress(): Float { return (getDisplayPosition() / 1000).toFloat() / (duration / 1000).toFloat() } -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" +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" - 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( + 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) } - putInt(START_ACTION_BUNDLE, startAction) - if (startValue != null) + } + + 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) + } + } + fun updateUI() { + updateUIListener?.invoke() + } - putBoolean(RESTART_BUNDLE, true) + 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) } } - 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) + 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 updateUI(id: Int? = null) { - // updateUIListener?.invoke() - updateUIEvent.invoke(id) + open fun setRecommendations(rec: List?, validApiName: String?) { + } - val updateUIEvent = Event() - //private var updateUIListener: (() -> Unit)? = null + private fun updateUI() { + syncModel.updateUserData() + viewModel.reloadEpisodes() + } + 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 + } - //protected open val resultLayout = R.layout.fragment_result_swipe + main { + val file = + ioWorkSafe { + context?.let { + VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( + it, + ep.id + ) + } + } - /* override var layout = 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 fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { + 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 - return super.onCreateView(inflater, container, savedInstanceState) - //return inflater.inflate(resultLayout, container, false) - } + /* + * 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 + */ - override fun onDestroyView() { - updateUIListener = null - super.onDestroyView() - } + // Do not use this.isTv, that is the player + val isTv = isTvSettings() + val hasEpisodes = + !(result_episodes?.adapter as? EpisodeAdapter?)?.cardList.isNullOrEmpty() - override fun onResume() { - afterPluginsLoadedEvent += ::reloadViewModel - super.onResume() - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) - } - } + if (isTv && hasEpisodes) { + // Make it impossible to focus anywhere else! + temporary_no_focus?.isFocusable = true + temporary_no_focus?.requestFocus() + } - override fun onDestroy() { - afterPluginsLoadedEvent -= ::reloadViewModel - super.onDestroy() - } + (result_episodes?.adapter as? EpisodeAdapter)?.updateList(episodes.value) - - private fun updateUI() { - syncModel.updateUserData() - viewModel.reloadEpisodes() - }*/ + 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() + } + } + } + } 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 restart : Boolean, + val playerAction: Int ) - fun Fragment.getStoredData(): StoredData? { - val context = this.context ?: this.activity ?: return null + private fun getStoredData(context: Context): StoredData? { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val url = arguments?.getString(URL_BUNDLE) ?: return null + val url = arguments?.getString(URL_BUNDLE) val apiName = arguments?.getString(API_NAME_BUNDLE) ?: return null val showFillers = settingsManager.getBoolean(context.getString(R.string.show_fillers_key), false) @@ -239,11 +492,6 @@ object ResultFragment { 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) @@ -258,10 +506,10 @@ object ResultFragment { season = resumeSeason ) } - return StoredData(url, apiName, showFillers, dubStatus, start, playerAction, restart) + return StoredData(url, apiName, showFillers, dubStatus, start, playerAction) } - /*private fun reloadViewModel(forceReload: Boolean) { + private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { val storedData = getStoredData(activity ?: context ?: return) ?: return @@ -280,6 +528,7 @@ object ResultFragment { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + result_cast_items?.adapter = ActorAdaptor() updateUIListener = ::updateUI @@ -293,6 +542,7 @@ object ResultFragment { context?.updateHasTrailers() activity?.loadCache() + activity?.fixPaddingStatusbar(result_top_bar) //activity?.fixPaddingStatusbar(result_barstatus) /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams @@ -309,13 +559,503 @@ object ResultFragment { 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( @@ -328,5 +1068,6 @@ object ResultFragment { ) } } - }*/ + } + } } 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 97bc49ea..b38e1765 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,132 +1,56 @@ 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.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.* +import com.lagradost.cloudstream3.APIHolder.updateHasTrailers +import com.lagradost.cloudstream3.mvvm.Some 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.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.AppUtils.isCastApiAvailable +import com.lagradost.cloudstream3.utils.AppUtils.openBrowser 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 com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +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.* -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 @@ -139,8 +63,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { return currentTrailerIndex + 1 < currentTrailers.size } - override fun playerError(exception: Throwable) { - if (player.getIsPlaying()) { // because we don't want random toasts in player + override fun playerError(exception: Exception) { + if (player.getIsPlaying()) { // because we dont want random toasts in player super.playerError(exception) } else { nextMirror() @@ -160,8 +84,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { startPosition = 0L, subtitles = emptySet(), subtitle = null, - autoPlay = false, - preview = false + autoPlay = false ) true } ?: run { @@ -173,28 +96,19 @@ open class ResultFragmentPhone : FullScreenPlayer() { //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) - // result_trailer_loading?.isVisible = isSuccess + result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer - 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) + 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 } - - // 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 + clearAnimation() + startAnimation(fadeIn) } + //player_view?.apply { //alpha = 0.0f //ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply { @@ -208,10 +122,18 @@ open class ResultFragmentPhone : FullScreenPlayer() { // 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 } - private fun setTrailers(trailers: List?) { + override fun setTrailers(trailers: List?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return currentTrailers = trailers?.sortedBy { -it.quality } ?: emptyList() @@ -219,19 +141,15 @@ open class ResultFragmentPhone : FullScreenPlayer() { } 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 -> - resultBinding?.resultCastItems?.let { + result_cast_items?.let { obs.unregister(it) } - - obs.removeGestureRegionsUpdateListener(gestureRegionsListener) + obs.removeGestureRegionsUpdateListener(this) } - updateUIEvent -= ::updateUI - binding = null - resultBinding = null - syncBinding = null - recommendationBinding = null super.onDestroyView() } @@ -249,284 +167,38 @@ open class ResultFragmentPhone : FullScreenPlayer() { } 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) - // ===== 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) - } - } - } + player_open_source?.setOnClickListener { + currentTrailers.getOrNull(currentTrailerIndex)?.let { + context?.openBrowser(it.url) } } + result_overlapping_panels?.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) + result_overlapping_panels?.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) - playerBinding?.apply { - playerOpenSource.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { - context?.openBrowser(it.url) - } + result_recommendations?.spanCount = 3 + result_recommendations?.adapter = + SearchAdapter( + ArrayList(), + result_recommendations, + ) { callback -> + SearchHelper.handleSearchClickCallback(activity, callback) } + PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) + + result_cast_items?.let { + PanelsChildGestureRegionObserver.Provider.get().register(it) } - recommendationBinding?.apply { - resultRecommendationsList.apply { - spanCount = 3 - adapter = - SearchAdapter( - ArrayList(), - this, - ) { callback -> - SearchHelper.handleSearchClickCallback(callback) - } - } - } + result_back?.setOnClickListener { + activity?.popCurrentPage() + } /* result_bookmark_button?.setOnClickListener { @@ -539,527 +211,171 @@ open class ResultFragmentPhone : FullScreenPlayer() { } }*/ - 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 - ) + 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() } - - 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 } + }) - resultResumeSeriesButton.isVisible = !resume.isMovie - resultResumeSeriesButton.setOnClickListener { - viewModel.handleAction( - EpisodeClickEvent( - storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, - resume.result - ) + + 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 ) } } - } - - 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) + 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.movie) { data -> - resultBinding?.apply { - resultPlayMovie.isVisible = data is Resource.Success - downloadButton.isVisible = - data is Resource.Success && viewModel.currentRepo?.api?.hasDownloadSupport == true + observe(viewModel.episodesCountText) { count -> + result_episodes_text.setText(count) + } - (data as? Resource.Success)?.value?.let { (text, ep) -> - resultPlayMovie.setText(text) - resultPlayMovie.setOnClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) + 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) + } ) } - resultPlayMovie.setOnLongClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) - ) - return@setOnLongClickListener true + } + 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 } - 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) + 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.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) - - 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 - } - } - } - - observeNullable(viewModel.episodesCountText) { count -> - resultBinding?.resultEpisodesText.setText(count) - } - - 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 { + is Some.None -> { + loadingDialog?.dismissSafe(activity) 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) + observe(viewModel.selectedSeason) { text -> + result_season_button.setText(text) - if (resultDubSelect.isVisible && !resultSeasonButton.isVisible && !resultEpisodeSelect.isVisible && resultResumeParent.isVisible) { - setFocusUpAndDown(resultResumeSeriesButton, resultDubSelect) - } - } + 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) } - 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) + 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) + } + } + 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) } - } } // val preferDub = context?.getApiDubstatusSettings()?.all { it == DubStatus.Dubbed } == true observe(viewModel.dubSubSelections) { range -> - resultBinding?.resultDubSelect?.setOnClickListener { view -> + result_dub_select.setOnClickListener { view -> view?.context?.let { ctx -> view.popupMenuNoIconsAndNoStringRes(range .mapNotNull { (text, status) -> @@ -1068,26 +384,23 @@ open class ResultFragmentPhone : FullScreenPlayer() { text?.asStringNull(ctx) ?: return@mapNotNull null ) }) { - viewModel.changeDubStatus(DubStatus.entries[itemId]) + viewModel.changeDubStatus(DubStatus.values()[itemId]) } } } } observe(viewModel.rangeSelections) { range -> - resultBinding?.resultEpisodeSelect?.setOnClickListener { view -> + result_episode_select?.setOnClickListener { view -> view?.context?.let { ctx -> val names = range .mapNotNull { (text, r) -> r to (text?.asStringNull(ctx) ?: return@mapNotNull null) } - activity?.showDialog( - names.map { it.second }, - names.indexOfFirst { it.second == selectEpisodeRange }, - "", - false, - {}) { itemId -> + view.popupMenuNoIconsAndNoStringRes(names.mapIndexed { index, (_, name) -> + index to name + }) { viewModel.changeRange(names[itemId].first) } } @@ -1095,7 +408,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.seasonSelections) { seasonList -> - resultBinding?.resultSeasonButton?.setOnClickListener { view -> + result_season_button?.setOnClickListener { view -> view?.context?.let { ctx -> val names = seasonList @@ -1125,59 +438,54 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onPause() { super.onPause() - PanelsChildGestureRegionObserver.Provider.get() - .addGestureRegionsUpdateListener(gestureRegionsListener) + PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) } - private fun setRecommendations(rec: List?, validApiName: String?) { - val isInvalid = rec.isNullOrEmpty() - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName + override fun onGestureRegionsUpdate(gestureRegions: List) { + result_overlapping_panels?.setChildGestureRegions(gestureRegions) + } - recommendationBinding?.apply { - root.isGone = isInvalid - root.post { - rec?.let { list -> - (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) + override 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]) } } + } ?: run { + result_recommendations_filter_button?.isVisible = false } - 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 + result_recommendations?.post { + rec?.let { list -> + (result_recommendations?.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) } } } 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 1878f0b8..2bd8ff0f 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,88 +1,33 @@ 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.CommonActivity +import com.lagradost.cloudstream3.APIHolder.updateHasTrailers 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.FragmentResultTvBinding -import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.ResourceSome +import com.lagradost.cloudstream3.mvvm.Some 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.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.ExtractorLink 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.setImage +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import kotlinx.android.synthetic.main.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() - } +class ResultFragmentTv : ResultFragment() { + override val resultLayout = R.layout.fragment_result_tv private var currentRecommendations: List = emptyList() @@ -91,15 +36,12 @@ class ResultFragmentTv : Fragment() { is EpisodeRange -> { viewModel.changeRange(data) } - is Int -> { viewModel.changeSeason(data) } - is DubStatus -> { viewModel.changeDubStatus(data) } - is String -> { setRecommendations(currentRecommendations, data) } @@ -121,800 +63,178 @@ class ResultFragmentTv : Fragment() { } } -// 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() + private fun hasNoFocus(): Boolean { + val focus = activity?.currentFocus + if (focus == null || !focus.isVisible) return true + return focus == this.result_root } - private fun setRecommendations(rec: List?, validApiName: String?) { + 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?) { currentRecommendations = rec ?: emptyList() val isInvalid = rec.isNullOrEmpty() - 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()) + 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()) - 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 - } + 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 } } 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) - // ===== 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 { + result_episodes?.layoutManager = + //LinearListLayout(result_episodes ?: return, result_episodes?.context).apply { + LinearListLayout(result_episodes?.context).apply { setHorizontal() } - - val aboveCast = listOf( - binding?.resultEpisodesShow, - binding?.resultBookmark, - binding?.resultFavorite, - binding?.resultSubscribe, - ).firstOrNull { - it?.isVisible == true - } - resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { - toggleEpisodes(false) - } + (result_episodes?.adapter as EpisodeAdapter?)?.apply { + layout = R.layout.result_episode_both_tv } + //result_episodes?.setMaxViewPoolSize(0, Int.MAX_VALUE) - observeNullable(viewModel.resumeWatching) { resume -> - binding?.apply { + result_season_selection.setAdapter() + result_range_selection.setAdapter() + result_dub_selection.setAdapter() + result_recommendations_filter_selection.setAdapter() - if (resume == null) { - return@observeNullable - } - resultResumeSeries.isVisible = true - resultPlayMovie.isVisible = false - resultPlaySeries.isVisible = false + observe(viewModel.selectPopup) { popup -> + when (popup) { + is Some.Success -> { + popupDialog?.dismissSafe(activity) - // 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 - } + popupDialog = activity?.let { act -> + val pop = popup.value + val options = pop.getOptions(act) + val title = pop.getTitle(act) - 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 + act.showBottomDialogInstant( + options, title, { + popupDialog = null + pop.callback(null) + }, { + popupDialog = null + pop.callback(it) + } ) - ) - } - - 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) + } + is Some.None -> { + popupDialog?.dismissSafe(activity) + popupDialog = null + } + } + } - 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) + 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) + + builder.show() + + builder } } - } - } - - 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) - ) - } - 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 - } - } - } - - 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) - } - ) - } - } - - 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 { + is Some.None -> { + loadingDialog?.dismissSafe(activity) loadingDialog = null - viewModel.cancelLinks() } - //builder.setOnCancelListener { - // it?.dismiss() - //} - builder.setCanceledOnTouchOutside(true) - builder.show() - builder } - } - observeNullable(viewModel.episodesCountText) { count -> - binding?.resultEpisodesText.setText(count) + observe(viewModel.episodesCountText) { count -> + result_episodes_text.setText(count) } observe(viewModel.selectedRangeIndex) { selected -> - binding?.resultRangeSelection.select(selected) + result_range_selection.select(selected) } observe(viewModel.selectedSeasonIndex) { selected -> - binding?.resultSeasonSelection.select(selected) + result_season_selection.select(selected) } observe(viewModel.selectedDubStatusIndex) { selected -> - binding?.resultDubSelection.select(selected) + result_dub_selection.select(selected) } observe(viewModel.rangeSelections) { - binding?.resultRangeSelection.update(it) + result_range_selection.update(it) } observe(viewModel.dubSubSelections) { - binding?.resultDubSelection.update(it) + result_dub_selection.update(it) } observe(viewModel.seasonSelections) { - binding?.resultSeasonSelection.update(it) - } - observe(viewModel.recommendations) { recommendations -> - setRecommendations(recommendations, null) + result_season_selection.update(it) } - 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() - } + result_back?.setOnClickListener { + activity?.popCurrentPage() + } + + result_recommendations?.spanCount = 8 + result_recommendations?.adapter = + SearchAdapter( + ArrayList(), + result_recommendations, + ) { callback -> + SearchHelper.handleSearchClickCallback(activity, callback) } - } - - // 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 2ab60c2f..bf47209a 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,23 +3,28 @@ 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.lagradost.cloudstream3.CommonActivity.screenHeight -import com.lagradost.cloudstream3.CommonActivity.screenWidth -import com.lagradost.cloudstream3.LoadResponse +import com.discord.panels.PanelsChildGestureRegionObserver 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.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +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.* -open class ResultTrailerPlayer : ResultFragmentPhone() { + +open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreenPlayer(), + PanelsChildGestureRegionObserver.GestureRegionsListener, IOnBackPressed { override var lockRotation = false override var isFullScreenPlayer = false @@ -29,13 +34,13 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { const val TAG = "RESULT_TRAILER" } - private var playerWidthHeight: Pair? = null + var playerWidthHeight: Pair? = null override fun nextEpisode() {} override fun prevEpisode() {} - override fun playerPositionChanged(position: Long, duration : Long) {} + override fun playerPositionChanged(posDur: Pair) {} override fun nextMirror() {} @@ -47,8 +52,6 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { 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) { @@ -57,13 +60,13 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { screenHeight } - //result_trailer_loading?.isVisible = false - resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer - binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer + result_trailer_loading?.isVisible = false + result_smallscreen_holder?.isVisible = !isFullScreenPlayer + result_fullscreen_holder?.isVisible = isFullScreenPlayer val to = sw * h / w - resultBinding?.fragmentTrailer?.playerBackground?.apply { + player_background?.apply { isVisible = true layoutParams = FrameLayout.LayoutParams( @@ -72,17 +75,16 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { ) } - playerBinding?.playerIntroPlay?.apply { + player_intro_play?.apply { layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight - ?: FrameLayout.LayoutParams.MATCH_PARENT + result_top_holder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT ) } - if (playerBinding?.playerIntroPlay?.isGone == true) { - resultBinding?.resultTopHolder?.apply { + if (player_intro_play?.isGone == true) { + result_top_holder?.apply { val anim = ValueAnimator.ofInt( measuredHeight, @@ -102,8 +104,8 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } } - override fun playerDimensionsLoaded(width: Int, height : Int) { - playerWidthHeight = width to height + override fun playerDimensionsLoaded(widthHeight: Pair) { + playerWidthHeight = widthHeight fixPlayerSize() } @@ -112,7 +114,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { override fun openOnlineSubPicker( context: Context, - loadResponse: LoadResponse?, + imdbId: Long?, dismissCallback: () -> Unit ) { } @@ -123,59 +125,58 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { override fun onTracksInfoChanged() {} override fun exitedPipMode() {} + + override fun onGestureRegionsUpdate(gestureRegions: List) {} + private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen - - playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + player_fullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) if (fullscreen) { enterFullscreen() - binding?.apply { - resultTopBar.isVisible = false - resultFullscreenHolder.isVisible = true - resultMainHolder.isVisible = false - } - - resultBinding?.fragmentTrailer?.playerBackground?.let { view -> + 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) - binding?.resultFullscreenHolder?.addView(view) + result_fullscreen_holder?.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) - } + result_top_bar?.isVisible = true + result_fullscreen_holder?.isVisible = false + result_main_holder?.isVisible = true + player_background?.let { view -> + (view.parent as ViewGroup?)?.removeView(view) + result_smallscreen_holder?.addView(view) } exitFullscreen() } fixPlayerSize() uiReset() - - if (isFullScreenPlayer) { - activity?.attachBackPressedCallback { - updateFullscreen(false) - } - } else detachBackPressedCallback() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - playerBinding?.playerFullscreen?.setOnClickListener { + player_fullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() - playerBinding?.playerIntroPlay?.setOnClickListener { - playerBinding?.playerIntroPlay?.isGone = true - player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + player_intro_play?.setOnClickListener { + player_intro_play?.isGone = true + player.handleEvent(CSPlayerEvent.Play) 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 6443a923..6817af6a 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,13 +3,9 @@ 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 @@ -18,89 +14,51 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.APIHolder.getId 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.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.AppUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppUtils.isAppInstalled +import com.lagradost.cloudstream3.utils.AppUtils.isConnectedToChromecast 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 @@ -134,7 +92,6 @@ data class ResultData( val plotText: UiText, val apiName: UiText, val ratingText: UiText?, - val contentRatingText: UiText?, val vpnText: UiText?, val metaText: UiText?, val durationText: UiText?, @@ -148,18 +105,6 @@ 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) { @@ -197,25 +142,18 @@ 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 = when (airing.season) { - - null -> txt(R.string.next_episode_format, airing.episode) - else -> txt(R.string.next_season_episode_format, airing.season, airing.episode) - } + nextAiringEpisode = txt(R.string.next_episode_format, airing.episode) } } } @@ -264,16 +202,12 @@ 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 @@ -284,7 +218,8 @@ 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( - secondsToReadable(dur * 60, "0 mins") + R.string.duration_format, + dur ), onGoingText = if (this is EpisodeResponse) { txt( @@ -302,23 +237,6 @@ 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, @@ -384,7 +302,6 @@ fun SelectPopup.getOptions(context: Context): List { is SelectPopup.SelectArray -> { this.options.map { it.first.asString(context) } } - is SelectPopup.SelectText -> options.map { it.asString(context) } } } @@ -396,7 +313,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) @@ -417,7 +334,7 @@ class ResultViewModel2 : ViewModel() { private var currentIndex: EpisodeIndexer? = null private var currentRange: EpisodeRange? = null private var currentShowFillers: Boolean = false - var currentRepo: APIRepository? = null + private var currentRepo: APIRepository? = null private var currentId: Int? = null private var fillers: Map = emptyMap() private var generator: IGenerator? = null @@ -432,17 +349,17 @@ class ResultViewModel2 : ViewModel() { MutableLiveData(null) val page: LiveData?> = _page - private val _episodes: MutableLiveData>?> = - MutableLiveData(Resource.Loading()) - val episodes: LiveData>?> = _episodes + private val _episodes: MutableLiveData>> = + MutableLiveData(ResourceSome.Loading()) + val episodes: LiveData>> = _episodes - private val _movie: MutableLiveData>?> = - MutableLiveData(null) - val movie: LiveData>?> = _movie + private val _movie: MutableLiveData>> = + MutableLiveData(ResourceSome.None) + val movie: LiveData>> = _movie - private val _episodesCountText: MutableLiveData = - MutableLiveData(null) - val episodesCountText: LiveData = _episodesCountText + private val _episodesCountText: MutableLiveData> = + MutableLiveData(Some.None) + val episodesCountText: LiveData> = _episodesCountText private val _trailers: MutableLiveData> = MutableLiveData(mutableListOf()) @@ -464,16 +381,16 @@ class ResultViewModel2 : ViewModel() { MutableLiveData(emptyList()) val recommendations: LiveData> = _recommendations - private val _selectedRange: MutableLiveData = - MutableLiveData(null) - val selectedRange: LiveData = _selectedRange + private val _selectedRange: MutableLiveData> = + MutableLiveData(Some.None) + val selectedRange: LiveData> = _selectedRange - private val _selectedSeason: MutableLiveData = - MutableLiveData(null) - val selectedSeason: LiveData = _selectedSeason + private val _selectedSeason: MutableLiveData> = + MutableLiveData(Some.None) + val selectedSeason: LiveData> = _selectedSeason - private val _selectedDubStatus: MutableLiveData = MutableLiveData(null) - val selectedDubStatus: LiveData = _selectedDubStatus + private val _selectedDubStatus: MutableLiveData> = MutableLiveData(Some.None) + val selectedDubStatus: LiveData> = _selectedDubStatus private val _selectedRangeIndex: MutableLiveData = MutableLiveData(-1) @@ -486,32 +403,48 @@ class ResultViewModel2 : ViewModel() { private val _selectedDubStatusIndex: MutableLiveData = MutableLiveData(-1) val selectedDubStatusIndex: LiveData = _selectedDubStatusIndex - private val _loadedLinks: MutableLiveData = MutableLiveData(null) - val loadedLinks: LiveData = _loadedLinks + private val _loadedLinks: MutableLiveData> = MutableLiveData(Some.None) + val loadedLinks: LiveData> = _loadedLinks - private val _resumeWatching: MutableLiveData = - MutableLiveData(null) - val resumeWatching: LiveData = _resumeWatching + private val _resumeWatching: MutableLiveData> = + MutableLiveData(Some.None) + 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 { @@ -528,16 +461,12 @@ class ResultViewModel2 : ViewModel() { ) ) - private fun getRanges( - allEpisodes: Map>, - EPISODE_RANGE_SIZE: Int - ): Map> { + private fun getRanges(allEpisodes: Map>): 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( @@ -568,8 +497,7 @@ class ResultViewModel2 : ViewModel() { val episodeNumber = episodes[currentIndex].episode if (episodeNumber < currentMin) { currentMin = episodeNumber - } - if (episodeNumber > currentMax) { + } else if (episodeNumber > currentMax) { currentMax = episodeNumber } ++currentIndex @@ -643,10 +571,12 @@ 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, createNotificationCallback = {} - ) + null + ) { + // no notification + } } } @@ -665,9 +595,6 @@ class ResultViewModel2 : ViewModel() { TvType.Live -> "LiveStreams" TvType.NSFW -> "NSFW" TvType.Others -> "Others" - TvType.Music -> "Music" - TvType.AudioBook -> "AudioBooks" - TvType.CustomMedia -> "Media" } } @@ -723,13 +650,13 @@ class ResultViewModel2 : ViewModel() { DOWNLOAD_HEADER_CACHE, parentId.toString(), VideoDownloadHelper.DownloadHeaderCached( - apiName = apiName, - url = url, - type = currentType, - name = currentHeaderName, - poster = currentPoster, - id = parentId, - cacheTime = System.currentTimeMillis(), + apiName, + url, + currentType, + currentHeaderName, + currentPoster, + parentId, + System.currentTimeMillis(), ) ) @@ -740,15 +667,15 @@ class ResultViewModel2 : ViewModel() { ), // 3 deep folder for faster acess episode.id.toString(), VideoDownloadHelper.DownloadEpisodeCached( - 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(), + episode.name, + episode.poster, + episode.episode, + episode.season, + episode.id, + parentId, + episode.rating, + episode.description, + System.currentTimeMillis(), ) ) @@ -774,7 +701,7 @@ class ResultViewModel2 : ViewModel() { ) ) } - .map { ExtractorSubtitleLink(it.name, it.url, "") }.take(3) + .map { ExtractorSubtitleLink(it.name, it.url, "") } .forEach { link -> val fileName = VideoDownloadManager.getFileName(context, meta) downloadSubtitle(context, link, fileName, folder) @@ -800,7 +727,7 @@ class ResultViewModel2 : ViewModel() { val generator = RepoLinkGenerator(listOf(episode)) val currentLinks = mutableSetOf() val currentSubs = mutableSetOf() - generator.generateLinks(clearCache = false, LoadType.Chromecast, callback = { + generator.generateLinks(clearCache = false, isCasting = false, callback = { it.first?.let { link -> currentLinks.add(link) } @@ -811,6 +738,7 @@ class ResultViewModel2 : ViewModel() { if (currentLinks.isEmpty()) { main { showToast( + activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT ) @@ -819,6 +747,7 @@ class ResultViewModel2 : ViewModel() { } else { main { showToast( + activity, R.string.download_started, Toast.LENGTH_SHORT ) @@ -865,84 +794,13 @@ class ResultViewModel2 : ViewModel() { private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) val watchStatus: LiveData get() = _watchStatus - private val _selectPopup: MutableLiveData = MutableLiveData(null) - val selectPopup: LiveData = _selectPopup + private val _selectPopup: MutableLiveData> = MutableLiveData(Some.None) + val selectPopup: LiveData> get() = _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)) - 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) - } + fun updateWatchStatus(status: WatchType) { + updateWatchStatus(currentResponse ?: return, status) + _watchStatus.postValue(status) } private fun startChromecast( @@ -951,282 +809,11 @@ class ResultViewModel2 : ViewModel() { isVisible: Boolean = true ) { if (activity == null) return - loadLinks(result, isVisible = isVisible, LoadType.Chromecast) { data -> + loadLinks(result, isVisible = isVisible, isCasting = true) { 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, @@ -1256,22 +843,23 @@ class ResultViewModel2 : ViewModel() { } fun cancelLinks() { + println("called::cancelLinks") currentLoadLinkJob?.cancel() currentLoadLinkJob = null - _loadedLinks.postValue(null) + _loadedLinks.postValue(Some.None) } private fun postPopup(text: UiText, options: List, callback: suspend (Int?) -> Unit) { _selectPopup.postValue( - SelectPopup.SelectText( + some(SelectPopup.SelectText( text, options ) { value -> viewModelScope.launchSafe { - _selectPopup.postValue(null) + _selectPopup.postValue(Some.None) callback.invoke(value) } - } + }) ) } @@ -1282,22 +870,22 @@ class ResultViewModel2 : ViewModel() { callback: suspend (Int?) -> Unit ) { _selectPopup.postValue( - SelectPopup.SelectArray( + some(SelectPopup.SelectArray( text, options, ) { value -> viewModelScope.launchSafe { - _selectPopup.postValue(null) + _selectPopup.value = Some.None callback.invoke(value) } - } + }) ) } private fun loadLinks( result: ResultEpisode, isVisible: Boolean, - type: LoadType, + isCasting: Boolean, clearCache: Boolean = false, work: suspend (CoroutineScope.(LinkLoadingResult) -> Unit) ) { @@ -1306,7 +894,7 @@ class ResultViewModel2 : ViewModel() { val links = loadLinks( result, isVisible = isVisible, - type = type, + isCasting = isCasting, clearCache = clearCache ) if (!this.isActive) return@ioSafe @@ -1317,20 +905,14 @@ class ResultViewModel2 : ViewModel() { private var currentLoadLinkJob: Job? = null private fun acquireSingleLink( result: ResultEpisode, - type: LoadType, + isCasting: Boolean, text: UiText, callback: (Pair) -> Unit, ) { - loadLinks(result, isVisible = true, type) { links -> - // Could not find a better way to do this - val context = AcraApplication.context + loadLinks(result, isVisible = true, isCasting = isCasting) { links -> postPopup( text, - links.links.apmap { - val size = - it.getVideoSize()?.let { size -> " " + formatFileSize(context, size) } ?: "" - txt("${it.name} ${Qualities.getStringByInt(it.quality)}$size") - }) { + links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") }) { callback.invoke(links to (it ?: return@postPopup)) } } @@ -1338,10 +920,11 @@ class ResultViewModel2 : ViewModel() { private fun acquireSingleSubtitle( result: ResultEpisode, + isCasting: Boolean, text: UiText, callback: (Pair) -> Unit, ) { - loadLinks(result, isVisible = true, type = LoadType.Unknown) { links -> + loadLinks(result, isVisible = true, isCasting = isCasting) { links -> postPopup( text, links.subs.map { txt(it.name) }) @@ -1354,7 +937,7 @@ class ResultViewModel2 : ViewModel() { private suspend fun CoroutineScope.loadLinks( result: ResultEpisode, isVisible: Boolean, - type: LoadType, + isCasting: Boolean, clearCache: Boolean = false, ): LinkLoadingResult { val tempGenerator = RepoLinkGenerator(listOf(result)) @@ -1363,12 +946,12 @@ class ResultViewModel2 : ViewModel() { val subs: MutableSet = mutableSetOf() fun updatePage() { if (isVisible && isActive) { - _loadedLinks.postValue(LinkProgress(links.size, subs.size)) + _loadedLinks.postValue(some(LinkProgress(links.size, subs.size))) } } try { updatePage() - tempGenerator.generateLinks(clearCache, type, { (link, _) -> + tempGenerator.generateLinks(clearCache, isCasting, { (link, _) -> if (link != null) { links += link updatePage() @@ -1380,7 +963,7 @@ class ResultViewModel2 : ViewModel() { } catch (e: Exception) { logError(e) } finally { - _loadedLinks.postValue(null) + _loadedLinks.postValue(Some.None) } return LinkLoadingResult(sortUrls(links), sortSubs(subs)) @@ -1388,7 +971,7 @@ class ResultViewModel2 : ViewModel() { private fun launchActivity( activity: Activity?, - resumeApp: MainActivity.Companion.ResultResume, + resumeApp: ResultResume, id: Int? = null, work: suspend (Intent.(Activity) -> Unit) ): Job? { @@ -1402,9 +985,9 @@ class ResultViewModel2 : ViewModel() { logError(t) main { if (t is ActivityNotFoundException) { - showToast(txt(R.string.app_not_found_error), Toast.LENGTH_LONG) + showToast(activity, txt(R.string.app_not_found_error), Toast.LENGTH_LONG) } else { - showToast(t.toString(), Toast.LENGTH_LONG) + showToast(activity, t.toString(), Toast.LENGTH_LONG) } } } @@ -1501,21 +1084,16 @@ class ResultViewModel2 : ViewModel() { 1L } - // 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 - } + component = VLC_COMPONENT putExtra("from_start", !resume) putExtra("position", position) } - fun handleAction(click: EpisodeClickEvent) = + fun handleAction(activity: Activity?, click: EpisodeClickEvent) = viewModelScope.launchSafe { - handleEpisodeClickEvent(click) + handleEpisodeClickEvent(activity, click) } data class ExternalApp( @@ -1545,7 +1123,7 @@ class ResultViewModel2 : ViewModel() { _episodeSynopsis.postValue(null) } - private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { + private suspend fun handleEpisodeClickEvent(activity: Activity?, click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { val options = mutableListOf>() @@ -1557,13 +1135,6 @@ 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) { @@ -1591,7 +1162,7 @@ class ResultViewModel2 : ViewModel() { // Do not add mark as watched on movies if (!listOf(TvType.Movie, TvType.AnimeMovie).contains(click.data.tvType)) { val isWatched = - getVideoWatchState(click.data.id) == VideoWatchState.Watched + DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched @@ -1610,26 +1181,27 @@ 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) } @@ -1645,6 +1217,7 @@ class ResultViewModel2 : ViewModel() { acquireSingleSubtitle( click.data, + false, txt(R.string.episode_action_download_subtitle) ) { (links, index) -> downloadSubtitle( @@ -1660,16 +1233,15 @@ class ResultViewModel2 : ViewModel() { ) ) showToast( + activity, R.string.download_started, Toast.LENGTH_SHORT ) } } - ACTION_SHOW_TOAST -> { - showToast(R.string.play_episode_toast, Toast.LENGTH_SHORT) + showToast(activity, R.string.play_episode_toast, Toast.LENGTH_SHORT) } - ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return downloadEpisode( @@ -1684,12 +1256,11 @@ class ResultViewModel2 : ViewModel() { response.url ) } - ACTION_DOWNLOAD_MIRROR -> { val response = currentResponse ?: return acquireSingleLink( click.data, - LoadType.InAppDownload, + false, txt(R.string.episode_action_download_mirror) ) { (result, index) -> ioSafe { @@ -1708,73 +1279,34 @@ class ResultViewModel2 : ViewModel() { ) } showToast( + activity, R.string.download_started, Toast.LENGTH_SHORT ) } } - ACTION_RELOAD_EPISODE -> { ioSafe { loadLinks( click.data, isVisible = false, - type = LoadType.InApp, + isCasting = false, clearCache = true ) } - showToast( - R.string.links_reloaded_toast, - Toast.LENGTH_SHORT - ) } - ACTION_CHROME_CAST_MIRROR -> { acquireSingleLink( click.data, - LoadType.Chromecast, + isCasting = true, 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, - LoadType.Browser, + isCasting = true, txt(R.string.episode_action_play_in_browser) ) { (result, index) -> try { @@ -1785,26 +1317,29 @@ class ResultViewModel2 : ViewModel() { logError(e) } } - ACTION_COPY_LINK -> { acquireSingleLink( click.data, - LoadType.ExternalApp, + isCasting = true, 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] - clipboardHelper(txt(link.name), link.url) + val clip = ClipData.newPlainText(link.name, link.url) + serviceClipboard.setPrimaryClip(clip) + showToast(act, R.string.copy_link_toast, Toast.LENGTH_SHORT) } } - ACTION_CHROME_CAST_EPISODE -> { startChromecast(activity, click.data) } - ACTION_PLAY_EPISODE_IN_VLC_PLAYER -> { - loadLinks(click.data, isVisible = true, LoadType.ExternalApp) { links -> + loadLinks(click.data, isVisible = true, isCasting = true) { links -> if (links.links.isEmpty()) { - showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) + showToast(activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT) return@loadLinks } @@ -1815,10 +1350,9 @@ class ResultViewModel2 : ViewModel() { ) } } - ACTION_PLAY_EPISODE_IN_WEB_VIDEO -> acquireSingleLink( click.data, - LoadType.Chromecast, + isCasting = true, txt( R.string.episode_action_play_in_format, txt(R.string.player_settings_play_in_web) @@ -1832,10 +1366,9 @@ class ResultViewModel2 : ViewModel() { result.subs ) } - ACTION_PLAY_EPISODE_IN_MPV -> acquireSingleLink( click.data, - LoadType.Chromecast, + isCasting = true, txt( R.string.episode_action_play_in_format, txt(R.string.player_settings_play_in_mpv) @@ -1848,43 +1381,34 @@ class ResultViewModel2 : ViewModel() { result.subs ) } - ACTION_PLAY_EPISODE_IN_PLAYER -> { val data = currentResponse?.syncData?.toList() ?: emptyList() val list = HashMap().apply { putAll(data) } - 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 - ) - ) - } - } + 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 + ) + ) + } ACTION_MARK_AS_WATCHED -> { val isWatched = - getVideoWatchState(click.data.id) == VideoWatchState.Watched + DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched if (isWatched) { - setVideoWatchState(click.data.id, VideoWatchState.None) + DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.None) } else { - setVideoWatchState(click.data.id, VideoWatchState.Watched) + DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.Watched) } // Kinda dirty to reload all episodes :( @@ -1898,134 +1422,85 @@ 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") - 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 + 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 - } - - 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 + if (this is EpisodeResponse) { + nextAiring = nextAiring ?: meta.nextAiring } for ((k, v) in syncs ?: emptyMap()) { syncData[k] = v } - argamap( - { - if (this !is AnimeLoadResponse) return@argamap - // already exist, no need to run getTracker - if (this.getAniListId() != null && this.getMalId() != null) return@argamap + val realRecommendations = ArrayList() + // TODO: fix + val apiNames = apis.filter { + it.name.contains("gogoanime", true) || + it.name.contains("9anime", true) + }.map { + it.name + } - 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 + 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 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 - } + 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 } } } - this.episodes[dubStatus] = current - updateCount > 0 - }.any { it } - }) + } + this.episodes[dubStatus] = current + updateCount > 0 + }.any { it } + }) } return out to updateEpisodes } @@ -2049,7 +1524,6 @@ class ResultViewModel2 : ViewModel() { postSuccessful( value ?: return@launchSafe, - currentId ?: return@launchSafe, currentRepo ?: return@launchSafe, updateEpisodes ?: return@launchSafe, false @@ -2095,7 +1569,7 @@ class ResultViewModel2 : ViewModel() { list.subList(start, end).map { val posDur = getViewPos(it.id) val watchState = - getVideoWatchState(it.id) ?: VideoWatchState.None + DataStoreHelper.getVideoWatchState(it.id) ?: VideoWatchState.None it.copy( position = posDur?.position ?: 0, duration = posDur?.duration ?: 0, @@ -2108,10 +1582,10 @@ class ResultViewModel2 : ViewModel() { private fun postMovie() { val response = currentResponse - _episodes.postValue(null) + _episodes.postValue(ResourceSome.None) if (response == null) { - _movie.postValue(null) + _movie.postValue(ResourceSome.None) return } @@ -2121,18 +1595,18 @@ class ResultViewModel2 : ViewModel() { else -> { if (response.type.isLiveStream()) R.string.play_livestream_button - else if (response.isMovie()) // this wont break compatibility as you only need to override isMovieType + else if (response.type.isMovieType()) // this wont break compatibility as you only need to override isMovieType R.string.play_movie_button else null } } ) val data = getMovie() - _episodes.postValue(null) + _episodes.postValue(ResourceSome.None) if (text == null || data == null) { - _movie.postValue(null) + _movie.postValue(ResourceSome.None) } else { - _movie.postValue(Resource.Success(text to data)) + _movie.postValue(ResourceSome.Success(text to data)) } } @@ -2141,37 +1615,18 @@ class ResultViewModel2 : ViewModel() { postMovie() } else { _episodes.postValue( - Resource.Success( + ResourceSome.Success( getEpisodes( currentIndex ?: return, currentRange ?: return ) ) ) - _movie.postValue(null) + _movie.postValue(ResourceSome.None) } 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 @@ -2200,14 +1655,14 @@ class ResultViewModel2 : ViewModel() { val size = currentEpisodes[indexer]?.size _episodesCountText.postValue( - - if (isMovie) null else - txt( - R.string.episode_format, - size, - txt(if (size == 1) R.string.episode else R.string.episodes), - ) - + some( + if (isMovie) null else + txt( + R.string.episode_format, + size, + txt(if (size == 1) R.string.episode else R.string.episodes), + ) + ) ) _selectedSeasonIndex.postValue( @@ -2215,29 +1670,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 (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 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( @@ -2245,13 +1700,13 @@ class ResultViewModel2 : ViewModel() { ) _selectedRange.postValue( - - if (isMovie) null else if ((currentRanges[indexer]?.size ?: 0) > 1) { - txt(R.string.episodes_range, range.startEpisode, range.endEpisode) - } else { - null - } - + some( + if (isMovie) null else if ((currentRanges[indexer]?.size ?: 0) > 1) { + txt(R.string.episodes_range, range.startEpisode, range.endEpisode) + } else { + null + } + ) ) _selectedDubStatusIndex.postValue( @@ -2259,10 +1714,10 @@ class ResultViewModel2 : ViewModel() { ) _selectedDubStatus.postValue( - - if (isMovie || currentDubStatus.size <= 1) null else - txt(indexer.dubStatus) - + some( + if (isMovie || currentDubStatus.size <= 1) null else + txt(indexer.dubStatus) + ) ) currentId?.let { id -> @@ -2296,34 +1751,29 @@ class ResultViewModel2 : ViewModel() { } }*/ - _episodes.postValue(Resource.Success(ret)) + _episodes.postValue(ResourceSome.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, mainId, updateFillers) + postEpisodes(loadResponse, updateFillers) } - private suspend fun postEpisodes( - loadResponse: LoadResponse, - mainId: Int, - updateFillers: Boolean - ) { - _episodes.postValue(Resource.Loading()) + private suspend fun postEpisodes(loadResponse: LoadResponse, updateFillers: Boolean) { + _episodes.postValue(ResourceSome.Loading()) + + val mainId = loadResponse.getId() + currentId = mainId + + _watchStatus.postValue(getResultWatchState(mainId)) if (updateFillers && loadResponse is AnimeLoadResponse) { updateFillers(loadResponse.name) @@ -2341,15 +1791,6 @@ 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) @@ -2369,10 +1810,7 @@ class ResultViewModel2 : ViewModel() { i.description, fillers.getOrDefault(episode, false), loadResponse.type, - mainId, - totalIndex, - airDate = i.date, - runTime = i.runTime, + mainId ) val season = eps.seasonIndex ?: 0 @@ -2385,7 +1823,6 @@ class ResultViewModel2 : ViewModel() { } episodes } - is TvSeriesLoadResponse -> { val episodes: MutableMap> = mutableMapOf() @@ -2401,14 +1838,6 @@ 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, @@ -2425,10 +1854,7 @@ class ResultViewModel2 : ViewModel() { episode.description, null, loadResponse.type, - mainId, - totalIndex, - airDate = episode.date, - runTime = episode.runTime, + mainId ) val season = ep.seasonIndex ?: 0 @@ -2441,7 +1867,6 @@ class ResultViewModel2 : ViewModel() { } episodes } - is MovieLoadResponse -> { singleMap( buildResultEpisode( @@ -2459,12 +1884,10 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId, - null, + mainId ) ) } - is LiveStreamLoadResponse -> { singleMap( buildResultEpisode( @@ -2482,12 +1905,10 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId, - null + mainId ) ) } - is TorrentLoadResponse -> { singleMap( buildResultEpisode( @@ -2505,12 +1926,10 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId, - null + mainId ) ) } - else -> { mapOf() } @@ -2546,7 +1965,7 @@ class ResultViewModel2 : ViewModel() { } currentEpisodes = allEpisodes - val ranges = getRanges(allEpisodes, EPISODE_RANGE_SIZE) + val ranges = getRanges(allEpisodes) currentRanges = ranges @@ -2567,13 +1986,13 @@ class ResultViewModel2 : ViewModel() { postResume() } - private fun postResume() { - _resumeWatching.postValue(resume()) + fun postResume() { + _resumeWatching.postValue(some(resume())) } private fun resume(): ResumeWatchingStatus? { val correctId = currentId ?: return null - val resume = getLastWatched(correctId) + val resume = DataStoreHelper.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 @@ -2588,13 +2007,7 @@ class ResultViewModel2 : ViewModel() { ResumeProgress( progress = (viewPos.position / 1000).toInt(), maxProgress = (viewPos.duration / 1000).toInt(), - txt( - R.string.resume_remaining, - secondsToReadable( - ((viewPos.duration - viewPos.position) / 1_000).toInt(), - "0 mins" - ) - ) + txt(R.string.resume_time_left, (viewPos.duration - viewPos.position) / (60_000)) ) } @@ -2634,7 +2047,7 @@ class ResultViewModel2 : ViewModel() { trailerData.extractorUrl, trailerData.referer ?: "", Qualities.Unknown.value, - type = INFER_TYPE + trailerData.extractorUrl.contains(".m3u8") ) ) to arrayListOf() } else { @@ -2672,6 +2085,7 @@ class ResultViewModel2 : ViewModel() { for (ep in currentRange) { if (ep.getWatchProgress() > 0.9) continue handleAction( + activity, EpisodeClickEvent( getPlayerAction(activity), ep @@ -2681,7 +2095,6 @@ class ResultViewModel2 : ViewModel() { } } } - START_ACTION_LOAD_EP -> { val all = currentEpisodes.values.flatten() val episode = @@ -2692,6 +2105,7 @@ class ResultViewModel2 : ViewModel() { } ?: return@launchSafe handleAction( + activity, EpisodeClickEvent( getPlayerAction(activity), episode @@ -2701,67 +2115,6 @@ 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, @@ -2771,9 +2124,9 @@ class ResultViewModel2 : ViewModel() { autostart: AutoResume?, loadTrailers: Boolean = true, ) = - ioSafe { + viewModelScope.launchSafe { _page.postValue(Resource.Loading(url)) - _episodes.postValue(Resource.Loading()) + _episodes.postValue(ResourceSome.Loading()) preferDubStatus = dubStatus currentShowFillers = showFillers @@ -2789,7 +2142,7 @@ class ResultViewModel2 : ViewModel() { "This provider does not exist" ) ) - return@ioSafe + return@launchSafe } @@ -2800,15 +2153,21 @@ 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@ioSafe + return@launchSafe } - val validUrl = validUrlResource.value val repo = APIRepository(api) currentRepo = repo @@ -2817,13 +2176,12 @@ class ResultViewModel2 : ViewModel() { is Resource.Failure -> { _page.postValue(data) } - is Resource.Success -> { - if (!isActive) return@ioSafe + if (!isActive) return@launchSafe val loadResponse = ioWork { applyMeta(data.value, currentMeta, currentSync).first } - if (!isActive) return@ioSafe + if (!isActive) return@launchSafe val mainId = loadResponse.getId() preferDubStatus = getDub(mainId) ?: preferDubStatus @@ -2834,28 +2192,26 @@ class ResultViewModel2 : ViewModel() { DOWNLOAD_HEADER_CACHE, mainId.toString(), VideoDownloadHelper.DownloadHeaderCached( - apiName = apiName, - url = validUrl, - type = loadResponse.type, - name = loadResponse.name, - poster = loadResponse.posterUrl, - id = mainId, - cacheTime = System.currentTimeMillis(), + apiName, + validUrl, + loadResponse.type, + loadResponse.name, + loadResponse.posterUrl, + mainId, + System.currentTimeMillis(), ) ) if (loadTrailers) loadTrailers(data.value) postSuccessful( data.value, - mainId, updateEpisodes = true, updateFillers = showFillers, apiRepository = repo ) - if (!isActive) return@ioSafe + if (!isActive) return@launchSafe 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 8752e275..2e7ec529 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.databinding.ResultSelectionBinding -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings typealias SelectData = Pair @@ -17,9 +17,7 @@ class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter Unit) : RecyclerView.Adapter) { @@ -63,16 +71,22 @@ class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter Unit ) { - if (isLayout(TV)) { + val isTrueTv = isTrueTvSettings() + if (isTrueTv) { 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 51d3f50c..91415d26 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,9 +10,7 @@ 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.* @@ -33,23 +31,23 @@ class SyncViewModel : ViewModel() { private val repos = SyncApis - private val _metaResponse: MutableLiveData?> = + private val _metaResponse: MutableLiveData> = + MutableLiveData() + + val metadata: LiveData> get() = _metaResponse + + private val _userDataResponse: MutableLiveData?> = MutableLiveData(null) - val metadata: LiveData?> = _metaResponse - - private val _userDataResponse: MutableLiveData?> = - MutableLiveData(null) - - val userData: LiveData?> = _userDataResponse + val userData: LiveData?> get() = _userDataResponse // prefix, id - private val syncs = mutableMapOf() + private var syncs = mutableMapOf() //private val _syncIds: MutableLiveData> = // MutableLiveData(mutableMapOf()) //val syncIds: LiveData> get() = _syncIds - fun getSyncs(): Map { + fun getSyncs() : Map { return syncs } @@ -57,7 +55,7 @@ class SyncViewModel : ViewModel() { MutableLiveData(getMissing()) // pair of name idPrefix isSynced - val synced: LiveData> = _currentSynced + val synced: LiveData> get() = _currentSynced private fun getMissing(): List { return repos.map { @@ -108,7 +106,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) @@ -152,8 +150,7 @@ class SyncViewModel : ViewModel() { val user = userData.value if (user is Resource.Success) { - user.value.watchedEpisodes = episodes - _userDataResponse.postValue(Resource.Success(user.value)) + _userDataResponse.postValue(Resource.Success(user.value.copy(watchedEpisodes = episodes))) } } @@ -161,8 +158,7 @@ class SyncViewModel : ViewModel() { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { - user.value.score = score - _userDataResponse.postValue(Resource.Success(user.value)) + _userDataResponse.postValue(Resource.Success(user.value.copy(score = score))) } } @@ -171,8 +167,7 @@ class SyncViewModel : ViewModel() { if (which < -1 || which > 5) return // validate input val user = userData.value if (user is Resource.Success) { - user.value.status = SyncWatchType.fromInternalId(which) - _userDataResponse.postValue(Resource.Success(user.value)) + _userDataResponse.postValue(Resource.Success(user.value.copy(status = which))) } } @@ -190,16 +185,17 @@ class SyncViewModel : ViewModel() { fun modifyMaxEpisode(episodeNum: Int) { Log.i(TAG, "modifyMaxEpisode = $episodeNum") modifyData { status -> - status.watchedEpisodes = maxOf( - episodeNum, - status.watchedEpisodes ?: return@modifyData null + status.copy( + 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.AbstractSyncStatus) -> (SyncAPI.AbstractSyncStatus?))) = + private fun modifyData(update: ((SyncAPI.SyncStatus) -> (SyncAPI.SyncStatus?))) = ioSafe { syncs.amap { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> @@ -249,12 +245,8 @@ 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) } } @@ -281,33 +273,7 @@ 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 70919943..81ef8d57 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,7 +1,6 @@ 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 @@ -9,8 +8,9 @@ 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.AppContextUtils.html +import com.lagradost.cloudstream3.utils.AppUtils.html import com.lagradost.cloudstream3.utils.UIHelper.setImage sealed class UiText { @@ -20,13 +20,6 @@ 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( @@ -35,16 +28,6 @@ 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? { @@ -85,14 +68,12 @@ 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 } @@ -107,13 +88,7 @@ fun ImageView?.setImageImage(value: UiImage.Image, fadeIn: Boolean = true) { fun ImageView?.setImageDrawable(value: UiImage.Drawable) { if (this == null) return this.isVisible = true - this.setImage(UiImage.Drawable(value.resId)) -} - -fun ImageView?.setImageBitmap(value: UiImage.Bitmap) { - if (this == null) return - this.isVisible = true - this.setImageBitmap(value.bitmap) + setImageResource(value.resId) } @JvmName("imgNull") @@ -138,10 +113,6 @@ 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) } @@ -191,3 +162,11 @@ 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 f318401c..649641c8 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,15 +4,16 @@ 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 androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.R 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.UIHelper.isBottomLayout +import com.lagradost.cloudstream3.utils.Coroutines.main +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 */ @@ -38,23 +39,10 @@ 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()) SearchResultGridExpandedBinding.inflate( - inflater, - parent, - false - ) else SearchResultGridBinding.inflate( - inflater, - parent, - false - ) //R.layout.search_result_grid_expanded else R.layout.search_result_grid - - - + if (parent.context.IsBottomLayout()) R.layout.search_result_grid_expanded else R.layout.search_result_grid return CardViewHolder( - layout, + LayoutInflater.from(parent.context).inflate(layout, parent, false), clickCallback, resView ) @@ -83,26 +71,22 @@ class SearchAdapter( diffResult.dispatchUpdatesTo(this) } - class CardViewHolder( - val binding: ViewBinding, + class CardViewHolder + constructor( + itemView: View, private val clickCallback: (SearchClickCallback) -> Unit, resView: AutofitRecyclerView ) : - RecyclerView.ViewHolder(binding.root) { + RecyclerView.ViewHolder(itemView) { + val cardView: ImageView = itemView.imageView 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 ef10fcee..b4a38216 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,7 +3,6 @@ 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 @@ -12,7 +11,6 @@ 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 @@ -24,53 +22,47 @@ 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.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.AcraApplication.Companion.setKey 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.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.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.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey 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 { @@ -90,14 +82,13 @@ class SearchFragment : Fragment() { fun newInstance(query: String): Bundle { return Bundle().apply { - if(query.isNotBlank()) putString(SEARCH_QUERY, query) + putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() private var bottomSheetDialog: BottomSheetDialog? = null - var binding: FragmentSearchBinding? = null override fun onCreateView( inflater: LayoutInflater, @@ -108,24 +99,18 @@ class SearchFragment : Fragment() { WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() - - - 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 + return inflater.inflate( + if (isTvSettings()) R.layout.fragment_search_tv else R.layout.fragment_search, + container, + false + ) } private fun fixGrid() { activity?.getSpanCount()?.let { currentSpan = it } - binding?.searchAutofitResults?.spanCount = currentSpan + search_autofit_results.spanCount = currentSpan currentSpan = currentSpan HomeFragment.configEvent.invoke(currentSpan) } @@ -138,7 +123,6 @@ class SearchFragment : Fragment() { override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() - binding = null super.onDestroyView() } @@ -162,8 +146,7 @@ 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() @@ -198,15 +181,15 @@ class SearchFragment : Fragment() { searchViewModel.reloadRepos() context?.filterProviderByPreferredMedia()?.let { validAPIs -> bindChips( - binding?.tvtypesChipsScroll?.tvtypesChips, + home_select_group, selectedSearchTypes, validAPIs.flatMap { api -> api.supportedTypes }.distinct() ) { list -> if (selectedSearchTypes.toSet() != list.toSet()) { - DataStoreHelper.searchPreferenceTags = list + setKey(SEARCH_PREF_TAGS, selectedSearchTypes) selectedSearchTypes.clear() selectedSearchTypes.addAll(list) - search(binding?.mainSearch?.query?.toString()) + search(main_search?.query?.toString()) } } } @@ -216,41 +199,38 @@ class SearchFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.searchRoot) + context?.fixPaddingStatusbar(searchRoot) fixGrid() reloadRepos() - 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 + val adapter: RecyclerView.Adapter? = activity?.let { + SearchAdapter( + ArrayList(), + search_autofit_results, + ) { callback -> + SearchHelper.handleSearchClickCallback(activity, callback) + } } + search_autofit_results.adapter = adapter + search_loading_bar.alpha = 0f val searchExitIcon = - binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + main_search.findViewById(androidx.appcompat.R.id.search_close_btn) // val searchMagIcon = - // binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) - // searchMagIcon.scaleX = 0.65f - // searchMagIcon.scaleY = 0.65f + // main_search.findViewById(androidx.appcompat.R.id.search_mag_icon) + //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() + context?.let { ctx -> + val validAPIs = ctx.filterProviderByPreferredMedia() + selectedApis = ctx.getKey( + SEARCH_PREF_PROVIDERS, + defVal = validAPIs.map { it.name } + )!!.toMutableSet() + } - activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) - searchExitIcon?.setColorFilter(searchExitIconColor.data) - - selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() - - binding?.searchFilter?.setOnClickListener { searchView -> + search_filter.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() @@ -261,18 +241,9 @@ class SearchFragment : Fragment() { BottomSheetDialog(ctx) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - - val selectMainpageBinding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( - builder.layoutInflater, - null, - false - ) - builder.setContentView(selectMainpageBinding.root) + builder.setContentView(R.layout.home_select_mainpage) 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) } @@ -299,7 +270,7 @@ class SearchFragment : Fragment() { } fun updateList(types: List) { - DataStoreHelper.searchPreferenceTags = types + setKey(SEARCH_PREF_TAGS, types.map { it.name }) arrayAdapter.clear() currentValidApis = validAPIs.filter { api -> @@ -324,23 +295,21 @@ 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( - selectMainpageBinding.tvtypesChipsScroll.tvtypesChips, + dialog.home_select_group, selectedSearchTypes, - validAPIs.flatMap { api -> api.supportedTypes }.distinct() + TvType.values().toList() ) { 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() } @@ -357,13 +326,8 @@ class SearchFragment : Fragment() { } dialog.setOnDismissListener { - DataStoreHelper.searchPreferenceProviders = currentSelectedApis.toList() + context?.setKey(SEARCH_PREF_PROVIDERS, 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()) } @@ -373,18 +337,21 @@ class SearchFragment : Fragment() { val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true - selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() + selectedSearchTypes = context?.getKey>(SEARCH_PREF_TAGS) + ?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } + ?.toMutableList() + ?: mutableListOf(TvType.Movie, TvType.TvSeries) - if (isLayout(TV)) { - binding?.searchFilter?.isFocusable = true - binding?.searchFilter?.isFocusableInTouchMode = true + if (isTrueTvSettings()) { + search_filter.isFocusable = true + search_filter.isFocusableInTouchMode = true } - binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + main_search.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) - binding?.mainSearch?.let { + main_search?.let { hideKeyboard(it) } @@ -398,24 +365,24 @@ class SearchFragment : Fragment() { searchViewModel.clearSearch() searchViewModel.updateHistory() } - binding?.apply { - searchHistoryHolder.isVisible = showHistory - searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch - searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch - } + + search_history_holder?.isVisible = showHistory + + search_master_recycler?.isVisible = !showHistory && isAdvancedSearch + search_autofit_results?.isVisible = !showHistory && !isAdvancedSearch return true } }) - binding?.searchClearCallHistory?.setOnClickListener { + search_clear_call_history?.setOnClickListener { activity?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { - removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") + removeKeys(SEARCH_HISTORY_KEY) searchViewModel.updateHistory() } DialogInterface.BUTTON_NEGATIVE -> { @@ -442,8 +409,8 @@ class SearchFragment : Fragment() { } observe(searchViewModel.currentHistory) { list -> - binding?.searchClearCallHistory?.isVisible = list.isNotEmpty() - (binding?.searchHistoryRecycler?.adapter as? SearchHistoryAdaptor?)?.updateList(list) + search_clear_call_history?.isVisible = list.isNotEmpty() + (search_history_recycler.adapter as? SearchHistoryAdaptor?)?.updateList(list) } searchViewModel.updateHistory() @@ -453,20 +420,20 @@ class SearchFragment : Fragment() { is Resource.Success -> { it.value.let { data -> if (data.isNotEmpty()) { - (binding?.searchAutofitResults?.adapter as? SearchAdapter)?.updateList(data) + (search_autofit_results?.adapter as? SearchAdapter)?.updateList(data) } } - searchExitIcon?.alpha = 1f - binding?.searchLoadingBar?.alpha = 0f + searchExitIcon.alpha = 1f + search_loading_bar.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() - searchExitIcon?.alpha = 1f - binding?.searchLoadingBar?.alpha = 0f + searchExitIcon.alpha = 1f + search_loading_bar.alpha = 0f } is Resource.Loading -> { - searchExitIcon?.alpha = 0f - binding?.searchLoadingBar?.alpha = 1f + searchExitIcon.alpha = 0f + search_loading_bar.alpha = 1f } } } @@ -476,7 +443,7 @@ class SearchFragment : Fragment() { try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding?.searchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { + (search_master_recycler?.adapter as ParentItemAdapter?)?.apply { val newItems = list.map { ongoing -> val dataList = if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() @@ -508,9 +475,9 @@ class SearchFragment : Fragment() { }*/ //main_search.onActionViewExpanded()*/ - val masterAdapter = - ParentItemAdapter(fragment = this, id = "masterAdapter".hashCode(), { callback -> - SearchHelper.handleSearchClickCallback(callback) + val masterAdapter: RecyclerView.Adapter = + ParentItemAdapter(mutableListOf(), { callback -> + SearchHelper.handleSearchClickCallback(activity, callback) }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null @@ -523,11 +490,11 @@ class SearchFragment : Fragment() { SEARCH_HISTORY_OPEN -> { searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) - updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, searchItem.type.toMutableList()) - binding?.mainSearch?.setQuery(searchItem.searchText, true) + updateChips(home_select_group, searchItem.type.toMutableList()) + main_search?.setQuery(searchItem.searchText, true) } SEARCH_HISTORY_REMOVE -> { - removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) + removeKey(SEARCH_HISTORY_KEY, searchItem.key) searchViewModel.updateHistory() } else -> { @@ -536,33 +503,20 @@ class SearchFragment : Fragment() { } } - binding?.apply { - searchHistoryRecycler.adapter = historyAdapter - searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) - //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) + search_history_recycler?.adapter = historyAdapter + search_history_recycler?.layoutManager = GridLayoutManager(context, 1) - searchMasterRecycler.adapter = masterAdapter - //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) + search_master_recycler?.adapter = masterAdapter + search_master_recycler?.layoutManager = GridLayoutManager(context, 1) - 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 - } + // 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) } - // SubtitlesFragment.push(activity) //searchViewModel.search("iron man") //(activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") @@ -576,4 +530,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 ef1b8719..45336d5b 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.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper object SearchHelper { - fun handleSearchClickCallback(callback: SearchClickCallback) { + fun handleSearchClickCallback(activity: Activity?, callback: SearchClickCallback) { val card = callback.card when (callback.action) { SEARCH_ACTION_LOAD -> { - loadSearchResult(card) + activity.loadSearchResult(card) } SEARCH_ACTION_PLAY_FILE -> { if (card is DataStoreHelper.ResumeWatchingResult) { val id = card.id - if (id == null) { - showToast(R.string.error_invalid_id, Toast.LENGTH_SHORT) + if(id == null) { + showToast(activity, R.string.error_invalid_id, Toast.LENGTH_SHORT) } else { if (card.isFromDownload) { handleDownloadClick( - DownloadClickEvent( + activity, DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, VideoDownloadHelper.DownloadEpisodeCached( - 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(), + card.name, + card.posterUrl, + card.episode ?: 0, + card.season, + id, + card.parentId ?: return, + null, + null, + System.currentTimeMillis() ) ) ) } else { - loadSearchResult(card, START_ACTION_LOAD_EP, id) + activity.loadSearchResult(card, START_ACTION_LOAD_EP, id) } } } else { handleSearchClickCallback( + activity, SearchClickCallback(SEARCH_ACTION_LOAD, callback.view, -1, callback.card) ) } } SEARCH_ACTION_SHOW_METADATA -> { - if(isLayout(PHONE)) { // we only want this on phone as UI is not done yet on tv + if(!isTvSettings()) { // we only want this on phone as UI is not done yet on tv (activity as? MainActivity?)?.apply { loadPopup(callback.card) } ?: kotlin.run { - showToast(callback.card.name, Toast.LENGTH_SHORT) + showToast(activity, callback.card.name, Toast.LENGTH_SHORT) } } else { - showToast(callback.card.name, Toast.LENGTH_SHORT) + showToast(activity, 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 4ef5fa69..8132301b 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,12 +1,16 @@ 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 com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding +import kotlinx.android.synthetic.main.search_history_item.view.* data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -30,7 +34,8 @@ class SearchHistoryAdaptor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return CardViewHolder( - SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + LayoutInflater.from(parent.context) + .inflate(R.layout.search_history_item, parent, false), clickCallback, ) } @@ -58,25 +63,24 @@ class SearchHistoryAdaptor( diffResult.dispatchUpdatesTo(this) } - class CardViewHolder( - val binding: SearchHistoryItemBinding, + class CardViewHolder + constructor( + itemView: View, private val clickCallback: (SearchHistoryCallback) -> Unit, ) : - 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 + 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 fun bind(card: SearchHistoryItem) { - binding.apply { - homeHistoryTitle.text = card.searchText + title.text = card.searchText - homeHistoryRemove.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) - } - homeHistoryTab.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) - } + removeButton.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) + } + openButton.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 92575e58..3447ee32 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,21 +10,14 @@ import androidx.cardview.widget.CardView import androidx.core.view.isVisible import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager -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.* +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.utils.AppUtils.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() @@ -38,31 +31,33 @@ object SearchResultBuilder { } } - @SuppressLint("StringFormatInvalid") + /** + * @param nextFocusBehavior True if first, False if last, Null if between. + * Used to prevent escaping the adapter horizontally (focus wise). + */ 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.findViewById(R.id.imageView) - val cardText: TextView? = itemView.findViewById(R.id.imageText) + val cardView: ImageView = itemView.imageView + val cardText: TextView? = itemView.imageText - 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 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 textQuality: TextView? = itemView.findViewById(R.id.text_quality) - val shadow: View? = itemView.findViewById(R.id.title_shadow) + val bg: CardView = itemView.background_card - 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) + val bar: ProgressBar? = itemView.watchProgress + val playImg: ImageView? = itemView.search_item_download_play // Do logic @@ -71,25 +66,12 @@ 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) { @@ -160,42 +142,15 @@ object SearchResultBuilder { } } - bg.isFocusable = false - bg.isFocusableInTouchMode = false - if(!isLayout(TV)) { - bg.setOnClickListener { - click(it) - } - bg.setOnLongClickListener { - longClick(it) - return@setOnLongClickListener true - } + bg.setOnClickListener { + click(it) } - // - // - // 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 } @@ -203,26 +158,36 @@ 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 (isLayout(TV)) { - // bg.isFocusable = true - // bg.isFocusableInTouchMode = true - // bg.touchscreenBlocksFocus = false + if (isTrueTvSettings()) { + 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 839b9d3f..aceda644 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,7 +14,6 @@ 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 @@ -38,7 +37,7 @@ class SearchViewModel : ViewModel() { private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory - private var repos = synchronized(apis) { apis.map { APIRepository(it) } } + private var repos = apis.map { APIRepository(it) } fun clearSearch() { _searchResponse.postValue(Resource.Success(ArrayList())) @@ -49,7 +48,7 @@ class SearchViewModel : ViewModel() { private var onGoingSearch: Job? = null fun reloadRepos() { - repos = synchronized(apis) { apis.map { APIRepository(it) } } + repos = apis.map { APIRepository(it) } } fun searchAndCancel( @@ -65,7 +64,7 @@ class SearchViewModel : ViewModel() { fun updateHistory() = viewModelScope.launch { ioSafe { - val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { + val items = getKeys(SEARCH_HISTORY_KEY)?.mapNotNull { getKey(it) }?.sortedByDescending { it.searchedAt } ?: emptyList() _currentHistory.postValue(items) @@ -88,7 +87,7 @@ class SearchViewModel : ViewModel() { if (!isQuickSearch) { val key = query.hashCode().toString() setKey( - "$currentAccount/$SEARCH_HISTORY_KEY", + SEARCH_HISTORY_KEY, key, SearchHistoryItem( searchedAt = System.currentTimeMillis(), @@ -141,4 +140,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 71077e91..9e03079f 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,9 +3,11 @@ 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 d7bd69f1..e879f0df 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,29 +1,28 @@ 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( - private val cardList: List, + val cardList: List, + val layout: Int = R.layout.account_single, private val clickCallback: (AccountClickCallback) -> Unit ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return CardViewHolder( - AccountSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false), //LayoutInflater.from(parent.context).inflate(layout, parent, false), - - clickCallback + LayoutInflater.from(parent.context).inflate(layout, parent, false), clickCallback ) } @@ -43,19 +42,19 @@ class AccountAdapter( return cardList[position].accountIndex.toLong() } - 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)!! + 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)!! - @SuppressLint("StringFormatInvalid") fun bind(card: AuthAPI.LoginInfo) { // just in case name is null account index will show, should never happened - binding.accountName.text = card.name ?: "%s %d".format( - binding.accountName.context.getString(R.string.account), + accountName.text = card.name ?: "%s %d".format( + accountName.context.getString(R.string.account), card.accountIndex ) - binding.accountProfilePicture.isVisible = binding.accountProfilePicture.setImage(card.profilePicture) + pfp.isVisible = pfp.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 deleted file mode 100644 index aa513d87..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt +++ /dev/null @@ -1,56 +0,0 @@ -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 15f8735f..f9627e46 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,10 +1,8 @@ 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.FOCUS_DOWN +import android.view.View.* import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.annotation.UiThread @@ -13,58 +11,31 @@ 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.hideOn +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings 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 qrcode.QRCode +import kotlinx.android.synthetic.main.account_managment.* +import kotlinx.android.synthetic.main.account_switch.* +import kotlinx.android.synthetic.main.add_account_input.* -class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { +class SettingsAccount : PreferenceFragmentCompat() { companion object { /** Used by nginx plugin too */ fun showLoginInfo( @@ -72,18 +43,15 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { api: AccountManager, info: AuthAPI.LoginInfo ) { - if (activity == null) return - val binding: AccountManagmentBinding = - AccountManagmentBinding.inflate(activity.layoutInflater, null, false) val builder = - AlertDialog.Builder(activity, R.style.AlertDialogCustom) - .setView(binding.root) + AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) + .setView(R.layout.account_managment) val dialog = builder.show() - binding.accountMainProfilePictureHolder.isVisible = - binding.accountMainProfilePicture.setImage(info.profilePicture) + dialog.account_main_profile_picture_holder?.isVisible = + dialog.account_main_profile_picture?.setImage(info.profilePicture) == true - binding.accountLogout.setOnClickListener { + dialog.account_logout?.setOnClickListener { api.logOut() dialog.dismissSafe(activity) } @@ -92,28 +60,26 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { dialog.findViewById(R.id.account_name)?.text = it } - binding.accountSite.text = api.name - binding.accountSwitchAccount.setOnClickListener { + dialog.account_site?.text = api.name + dialog.account_switch_account?.setOnClickListener { dialog.dismissSafe(activity) showAccountSwitch(activity, api) } - if (isLayout(TV or EMULATOR)) { - binding.accountSwitchAccount.requestFocus() + if (isTvSettings()) { + dialog.account_switch_account?.requestFocus() } } - private fun showAccountSwitch(activity: FragmentActivity, api: AccountManager) { + 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(binding.root) + .setView(R.layout.account_switch) val dialog = builder.show() - binding.accountAdd.setOnClickListener { + dialog.account_add?.setOnClickListener { addAccount(activity, api) dialog?.dismissSafe(activity) } @@ -130,7 +96,7 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { } } api.accountIndex = ogIndex - val adapter = AccountAdapter(items) { + val adapter = AccountAdapter(items, R.layout.account_single) { dialog?.dismissSafe(activity) api.changeAccount(it.card.accountIndex) } @@ -143,128 +109,22 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { try { when (api) { is OAuth2API -> { - 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) - } - } - } + api.authenticate(activity) } - is InAppAuthAPI -> { - if (activity == null) return - val binding: AddAccountInputBinding = - AddAccountInputBinding.inflate(activity.layoutInflater, null, false) val builder = - AlertDialog.Builder(activity, R.style.AlertDialogCustom) - .setView(binding.root) + AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) + .setView(R.layout.add_account_input) val dialog = builder.show() - val visibilityMap = listOf( - binding.loginEmailInput to api.requiresEmail, - binding.loginPasswordInput to api.requiresPassword, - binding.loginServerInput to api.requiresServer, - binding.loginUsernameInput to api.requiresUsername + 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 ) - if (isLayout(TV or EMULATOR)) { + if (isTvSettings()) { visibilityMap.forEach { (input, isVisible) -> input.isVisible = isVisible @@ -285,77 +145,63 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { } } - 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 { + 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 { openBrowser( api.createAccountUrl ?: return@setOnClickListener, activity ) dialog.dismissSafe() } - - 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 + dialog.text1?.text = api.name if (api.storesPasswordInPlainText) { api.getLatestLoginData()?.let { data -> - binding.loginEmailInput.setText(data.email ?: "") - binding.loginServerInput.setText(data.server ?: "") - binding.loginUsernameInput.setText(data.username ?: "") - binding.loginPasswordInput.setText(data.password ?: "") + 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.applyBtt.setOnClickListener { + dialog.apply_btt?.setOnClickListener { val loginData = InAppAuthAPI.LoginData( - 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, + 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, ) ioSafe { - try { - showToast( - txt( - if (api.login(loginData)) R.string.authenticated_user else R.string.authenticated_user_fail, - api.name - ) - ) + val isSuccessful = try { + api.login(loginData) } 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) } - binding.cancelBtt.setOnClickListener { + dialog.cancel_btt?.setOnClickListener { dialog.dismissSafe(activity) } } - else -> { throw NotImplementedError("You are trying to add an account that has an unknown login method") } @@ -366,75 +212,27 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { } } - 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 = api.name + title = + getString(R.string.login_format).format(api.name, getString(R.string.account)) 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 88335eea..40c996cc 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,46 +1,37 @@ 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.children -import androidx.core.view.updateLayoutParams +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.MaterialToolbar -import com.lagradost.cloudstream3.BuildConfig +import androidx.preference.PreferenceManager 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.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.fixPaddingStatusbar 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 @@ -53,87 +44,37 @@ class SettingsFragment : Fragment() { } } - /** - * 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) - } - } - - /** - * 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)) { + if (isTvSettings()) { 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 - } - } - } - 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 { + settings_toolbar?.apply { setTitle(title) - if (isLayout(PHONE or EMULATOR)) { - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() - } + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + activity?.onBackPressed() } } - UIHelper.fixPaddingStatusbar(settingsToolbar) + context.fixPaddingStatusbar(settings_toolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { if (this == null) return - val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return - - settingsToolbar.apply { + settings_toolbar?.apply { setTitle(title) - 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() - } + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + activity?.onBackPressed() } } - UIHelper.fixPaddingStatusbar(settingsToolbar) + context.fixPaddingStatusbar(settings_toolbar) } fun getFolderSize(dir: File): Long { @@ -149,21 +90,61 @@ class SettingsFragment : Fragment() { return size } - } - override fun onDestroyView() { - binding = null - super.onDestroyView() + + 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") + } } - var binding: MainSettingsBinding? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View { - val localBinding = MainSettingsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root + ): View? { + return inflater.inflate(R.layout.main_settings, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -171,82 +152,43 @@ class SettingsFragment : Fragment() { activity?.navigate(id, Bundle()) } - /** used to debug leaks - showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : - ${VideoDownloadManager.downloadProgressEvent.size}") **/ + val isTrueTv = isTrueTvSettings() - fun hasProfilePictureFromAccountManagers(accountManagers: List): Boolean { - for (syncApi in accountManagers) { - val login = syncApi.loginInfo() - val pic = login?.profilePicture ?: continue + 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 + } + } - if (binding?.settingsProfilePic?.setImage( - pic, - errorImageDrawable = HomeFragment.errorProfilePic - ) == true - ) { - binding?.settingsProfileText?.text = login.name - return true // sync profile exists + 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 } } - 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() - } - } - - 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 7cb1a848..649aa634 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,6 +5,7 @@ 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 @@ -12,31 +13,22 @@ 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 @@ -46,19 +38,16 @@ 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 com.lagradost.safefile.SafeFile +import kotlinx.android.synthetic.main.add_remove_sites.* +import kotlinx.android.synthetic.main.add_site_input.* +import java.io.File -// 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 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - conf?.locales?.get(0)?.toString() ?: "en" - } else { - @Suppress("DEPRECATION") - conf?.locale?.toString() ?: "en" - } + return conf?.locale?.toString() ?: "en" } // idk, if you find a way of automating this it would be great @@ -67,72 +56,54 @@ 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("", "اللهجة النجدية", "ars"), - Triple("", "অসমীয়া", "as"), - Triple("", "български", "bg"), + Triple("", "български език", "bg"), Triple("", "বাংলা", "bn"), - Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"), + Triple("\uD83C\uDDE7\uD83C\uDDF7", "Brazilian Portuguese", "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", "hr"), + Triple("", "hrvatski jezik", "hr"), Triple("", "magyar", "hu"), - Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), - Triple("", "italiano", "it"), - Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"), - Triple("", "日本語 (にほんご)", "ja"), + Triple("\uD83C\uDDEE\uD83C\uDDE9", "Indonesian", "in"), + Triple("", "Italiano", "it"), + Triple("\uD83C\uDDEE\uD83C\uDDF1", "עִברִית", "iw"), Triple("", "ಕನ್ನಡ", "kn"), - Triple("", "한국어", "ko"), - Triple("", "lietuvių kalba", "lt"), - Triple("", "latviešu valoda", "lv"), - Triple("", "македонски", "mk"), + Triple("", "македонски јазик", "mk"), Triple("", "മലയാളം", "ml"), - Triple("", "bahasa Melayu", "ms"), - Triple("", "Malti", "mt"), - Triple("", "ဗမာစာ", "my"), - Triple("", "नेपाली", "ne"), Triple("", "Nederlands", "nl"), - 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("", "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("", "slovenčina", "sk"), Triple("", "Soomaaliga", "so"), Triple("", "svenska", "sv"), Triple("", "தமிழ்", "ta"), - Triple("", "ትግርኛ", "ti"), - Triple("", "Tagalog", "tl"), + Triple("", "Wikang Tagalog", "tl"), Triple("", "Türkçe", "tr"), - Triple("", "українська", "uk"), + Triple("", "Українська", "uk"), Triple("", "اردو", "ur"), Triple("", "Tiếng Việt", "vi"), - Triple("", "中文", "zh"), - Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), + Triple("", "中文 (Zhōngwén)", "zh"), + Triple("\uD83C\uDDF9\uD83C\uDDFC", "Chinese Traditional", "zh-rTW"), /* end language list */ -).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.second } //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( @@ -158,9 +129,8 @@ class SettingsGeneral : PreferenceFragmentCompat() { context.contentResolver.takePersistableUriPermission(uri, flags) - val file = SafeFile.fromUri(context, uri) - val filePath = file?.filePath() - println("Selected URI path: $uri - Full path: $filePath") + val file = UniFile.fromUri(context, uri) + println("Selected URI path: $uri - Full path: ${file.filePath}") // Stores the real URI using download_path_key // Important that the URI is stored instead of filepath due to permissions. @@ -169,7 +139,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { // From URI -> File path // File path here is purely for cosmetic purposes in settings - (filePath ?: uri.toString()).let { + (file.filePath ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context) .edit().putString(getString(R.string.download_path_pref), it).apply() } @@ -177,7 +147,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() - setPreferencesFromResource(R.xml.settings_general, rootKey) + setPreferencesFromResource(R.xml.settins_general, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) fun getCurrent(): MutableList { @@ -187,6 +157,9 @@ 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) -> @@ -210,20 +183,9 @@ 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 = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } + val providers = allProviders.distinctBy { it.javaClass }.sortedBy { it.name } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -232,23 +194,21 @@ 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(binding.root) + .setView(R.layout.add_site_input) val dialog = builder.create() dialog.show() - 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() + 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() val realLang = if (lang.isNullOrBlank()) provider.lang else lang if (url.isNullOrBlank() || name.isNullOrBlank() || realLang.length != 2) { - showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) + showToast(activity, R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -256,12 +216,10 @@ 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) } - binding.cancelBtt.setOnClickListener { + dialog.cancel_btt?.setOnClickListener { dialog.dismissSafe(activity) } } @@ -281,19 +239,18 @@ class SettingsGeneral : PreferenceFragmentCompat() { } fun showAddOrDelete() { - val binding : AddRemoveSitesBinding = AddRemoveSitesBinding.inflate(layoutInflater,null,false) val builder = AlertDialog.Builder(context ?: return, R.style.AlertDialogCustom) - .setView(binding.root) + .setView(R.layout.add_remove_sites) val dialog = builder.create() dialog.show() - binding.addSite.setOnClickListener { + dialog.add_site?.setOnClickListener { showAdd() dialog.dismissSafe(activity) } - binding.removeSite.setOnClickListener { + dialog.remove_site?.setOnClickListener { showDelete() dialog.dismissSafe(activity) } @@ -337,38 +294,34 @@ class SettingsGeneral : PreferenceFragmentCompat() { } return@setOnPreferenceClickListener true } - fun getDownloadDirs(): List { return normalSafeApiCall { - context?.let { ctx -> - val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() + val defaultDir = VideoDownloadManager.getDownloadDir()?.filePath - val first = listOf(defaultDir) - (try { - val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second } + // 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 } - (first + - ctx.getExternalFilesDirs("").mapNotNull { it.path } + - currentDir) - } catch (e: Exception) { - first - }).filterNotNull().distinct() - } + (first + + requireContext().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) - ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } + ?: VideoDownloadManager.getDownloadDir().toString() activity?.showBottomDialog( dirs + listOf("Custom"), @@ -397,30 +350,30 @@ class SettingsGeneral : PreferenceFragmentCompat() { } try { - beneneCount = + SettingsFragment.beneneCount = settingsManager.getInt(getString(R.string.benene_count), 0) getPref(R.string.benene_count)?.let { pref -> pref.summary = - if (beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( + if (SettingsFragment.beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( R.string.benene_count_text ).format( - beneneCount + SettingsFragment.beneneCount ) pref.setOnPreferenceClickListener { try { - beneneCount++ - if (beneneCount%20 == 0) { + SettingsFragment.beneneCount++ + if (SettingsFragment.beneneCount%20 == 0) { val intent = Intent(context, EasterEggMonke::class.java) startActivity(intent) } settingsManager.edit().putInt( getString(R.string.benene_count), - beneneCount + SettingsFragment.beneneCount ) .apply() it.summary = - getString(R.string.benene_count_text).format(beneneCount) + getString(R.string.benene_count_text).format(SettingsFragment.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 1753032a..33d41934 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,15 +7,9 @@ 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 @@ -29,26 +23,12 @@ 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) @@ -87,6 +67,10 @@ 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) @@ -105,10 +89,8 @@ 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.entries.map { it.value }.reversed().toMutableList() + val prefValues = Qualities.values().map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } @@ -116,7 +98,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_key), - Qualities.entries.last().value + Qualities.values().last().value ) activity?.showBottomDialog( @@ -131,30 +113,6 @@ 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) @@ -243,5 +201,6 @@ 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 cb7d25fd..3b01508d 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,19 +2,17 @@ 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.AppContextUtils.getApiDubstatusSettings -import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard @@ -24,7 +22,6 @@ class SettingsProviders : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) setPaddingBottom() - setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -34,7 +31,7 @@ class SettingsProviders : PreferenceFragmentCompat() { getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> - val dublist = DubStatus.entries + val dublist = DubStatus.values() val names = dublist.map { it.name } val currentList = ArrayList() @@ -59,20 +56,6 @@ 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 = @@ -96,7 +79,7 @@ class SettingsProviders : PreferenceFragmentCompat() { this.getString(R.string.prefer_media_type_key), selectedList.map { it.toString() }.toMutableSet() ).apply() - DataStoreHelper.currentHomePage = null + removeKey(USER_SELECTED_HOMEPAGE_API) //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } @@ -105,10 +88,8 @@ class SettingsProviders : PreferenceFragmentCompat() { getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { activity?.getApiProviderLangSettings()?.let { current -> - val languages = synchronized(APIHolder.apis) { - APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName - } + val languages = 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 8c3ad0ad..e2fd24ca 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,11 +9,10 @@ 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 @@ -24,12 +23,11 @@ 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.settings_ui, rootKey) + setPreferencesFromResource(R.xml.settins_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { @@ -88,9 +86,10 @@ 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() - val removeIncompatible = { text: String -> + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less val toRemove = prefValues - .mapIndexed { idx, s -> if (s.startsWith(text)) idx else null } + .mapIndexed { idx, s -> if (s.startsWith("Monet")) idx else null } .filterNotNull() var offset = 0 toRemove.forEach { idx -> @@ -99,12 +98,6 @@ 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()) @@ -128,8 +121,7 @@ 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 260c6674..f9ac3fee 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,50 +1,41 @@ 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 +import com.lagradost.cloudstream3.utils.BackupUtils.backup 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?) { @@ -53,30 +44,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { //val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.backup_key)?.setOnPreferenceClickListener { - 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() - ) - } + activity?.backup() return@setOnPreferenceClickListener true } @@ -92,9 +60,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> val builder = AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) - - val binding = LogcatBinding.inflate(layoutInflater, null, false) - builder.setView(binding.root) + .setView(R.layout.logcat) val dialog = builder.create() dialog.show() @@ -115,43 +81,47 @@ class SettingsUpdates : PreferenceFragmentCompat() { } val text = log.toString() - binding.text1.text = text + dialog.text1?.text = text - binding.copyBtt.setOnClickListener { - clipboardHelper(txt("Logcat"), text) - dialog.dismissSafe(activity) + 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.clearBtt.setOnClickListener { + dialog.clear_btt?.setOnClickListener { Runtime.getRuntime().exec("logcat -c") dialog.dismissSafe(activity) } - - binding.saveBtt.setOnClickListener { - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) + dialog.save_btt?.setOnClickListener { var fileStream: OutputStream? = null try { - fileStream = VideoDownloadManager.setupStream( + fileStream = + VideoDownloadManager.setupStream( it.context, - "logcat_${date}", + "logcat", null, "txt", false - ).openNew() - fileStream.writer().write(text) - dialog.dismissSafe(activity) - } catch (t: Throwable) { - logError(t) - showToast(t.message) + ).fileStream + fileStream?.writer()?.write(text) + } catch (e: Exception) { + logError(e) } finally { fileStream?.closeQuietly() + dialog.dismissSafe(activity) } } - - binding.closeBtt.setOnClickListener { + dialog.close_btt?.setOnClickListener { dialog.dismissSafe(activity) } - return@setOnPreferenceClickListener true } @@ -169,10 +139,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[num]) + .putInt(getString(R.string.apk_installer_key), prefValues[it]) .apply() } catch (e: Exception) { logError(e) @@ -186,6 +156,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { if (activity?.runAutoUpdate(false) == false) { activity?.runOnUiThread { showToast( + activity, R.string.no_update_found, Toast.LENGTH_SHORT ) @@ -194,27 +165,5 @@ 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 1b487629..7e60910d 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,47 +13,34 @@ 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.databinding.AddRepoInputBinding -import com.lagradost.cloudstream3.databinding.FragmentExtensionsBinding +import com.lagradost.cloudstream3.mvvm.Some 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.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.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog -import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppUtils.downloadAllPluginsDialog +import com.lagradost.cloudstream3.utils.AppUtils.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 { - val localBinding = FragmentExtensionsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root//inflater.inflate(R.layout.fragment_extensions, container, false) + ): View? { + return inflater.inflate(R.layout.fragment_extensions, container, false) } private fun View.setLayoutWidth(weight: Int) { @@ -87,89 +74,55 @@ class ExtensionsFragment : Fragment() { //context?.fixPaddingStatusbar(extensions_root) setUpToolbar(R.string.extensions) - setToolBarScrollFlags() - 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 - ) - - if (!isLayout(TV)) - binding?.addRepoButton?.let { button -> - button.post { - setPadding( - paddingLeft, - paddingTop, - paddingRight, - button.measuredHeight + button.marginTop + button.marginBottom - ) - } - } - - 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_recycler_view?.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() - } + ) + }, { 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 -> {} } + 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() - } - }) - } + 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() + } + }) observe(extensionViewModel.repositories) { - binding?.repoRecyclerView?.isVisible = it.isNotEmpty() - binding?.blankRepoScreen?.isVisible = it.isEmpty() - (binding?.repoRecyclerView?.adapter as? RepoAdapter)?.updateList(it) + repo_recycler_view?.isVisible = it.isNotEmpty() + blank_repo_screen?.isVisible = it.isEmpty() + (repo_recycler_view?.adapter as? RepoAdapter)?.updateList(it) } - /*binding?.repoRecyclerView?.apply { + repo_recycler_view?.apply { context?.let { ctx -> layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) } - }*/ + } // list_repositories?.setOnClickListener { // // Open webview on tv if browser fails @@ -186,31 +139,32 @@ class ExtensionsFragment : Fragment() { // } // } - observeNullable(extensionViewModel.pluginStats) { value -> - binding?.apply { - if (value == null) { - pluginStorageAppbar.isVisible = false + observe(extensionViewModel.pluginStats) { + when (it) { + is Some.Success -> { + val value = it.value - return@observeNullable + 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) } - - 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) + is Some.None -> { + plugin_storage_appbar?.isVisible = false } - pluginNotDownloadedTxt.setText(value.notDownloadedText) - pluginDisabledTxt.setText(value.disabledText) - pluginDownloadTxt.setText(value.downloadedText) } } - binding?.pluginStorageAppbar?.setOnClickListener { + plugin_storage_appbar?.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -222,18 +176,16 @@ 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(ctx, R.style.AlertDialogCustom) - .setView(binding.root) + AlertDialog.Builder(context ?: return@OnClickListener, R.style.AlertDialogCustom) + .setView(R.layout.add_repo_input) val dialog = builder.create() dialog.show() (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( 0 )?.text?.toString()?.let { copy -> - binding.repoUrlInput.setText(copy) + dialog.repo_url_input?.setText(copy) } // dialog.list_repositories?.setOnClickListener { @@ -243,62 +195,44 @@ class ExtensionsFragment : Fragment() { // } // dialog.text2?.text = provider.name - binding.applyBtt.setOnClickListener secondListener@{ - val name = binding.repoNameInput.text?.toString() + dialog.apply_btt?.setOnClickListener secondListener@{ + val name = dialog.repo_name_input?.text?.toString() ioSafe { - val url = binding.repoUrlInput.text?.toString() + val url = dialog.repo_url_input?.text?.toString() ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { - showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) + showToast(activity, 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 repository.name + else RepositoryManager.parseRepository(url)?.name ?: "No name" val newRepo = RepositoryData(fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() - - 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, - ) - } + this@ExtensionsFragment.activity?.downloadAllPluginsDialog(url, fixedName) } } dialog.dismissSafe(activity) } - binding.cancelBtt.setOnClickListener { + dialog.cancel_btt?.setOnClickListener { dialog.dismissSafe(activity) } } - val isTv = isLayout(TV) - binding?.apply { - addRepoButton.isGone = isTv - addRepoButtonImageviewHolder.isVisible = isTv + val isTv = isTrueTvSettings() + add_repo_button?.isGone = isTv + add_repo_button_imageview_holder?.isVisible = isTv - // Band-aid for Fire TV - pluginStorageAppbar.isFocusableInTouchMode = isTv - addRepoButtonImageview.isFocusableInTouchMode = 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) - 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 866d167c..63ed5357 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,6 +7,7 @@ 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 @@ -39,8 +40,8 @@ class ExtensionsViewModel : ViewModel() { private val _repositories = MutableLiveData>() val repositories: LiveData> = _repositories - private val _pluginStats: MutableLiveData = MutableLiveData(null) - val pluginStats: LiveData = _pluginStats + private val _pluginStats: MutableLiveData> = MutableLiveData(Some.None) + val pluginStats: LiveData> = _pluginStats //TODO CACHE GET REQUESTS // DO not use viewModelScope.launchSafe, it will ANR on slow internet @@ -77,7 +78,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(stats) + _pluginStats.postValue(Some.Success(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 d159539d..0c3d481b 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,11 +1,10 @@ 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 @@ -14,27 +13,24 @@ 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.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.utils.AppUtils.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 java.text.DecimalFormat -import kotlin.math.floor -import kotlin.math.log10 -import kotlin.math.pow -import org.junit.Test +import kotlinx.android.synthetic.main.repository_item.view.* import org.junit.Assert +import org.junit.Test +import java.text.DecimalFormat + data class PluginViewData( val plugin: Plugin, @@ -48,11 +44,9 @@ class PluginAdapter( private val plugins: MutableList = mutableListOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - 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) - + val layout = if(isTrueTvSettings()) R.layout.repository_item_tv else R.layout.repository_item return PluginViewHolder( - RepositoryItemBinding.bind(inflated) // may crash + LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } @@ -88,10 +82,8 @@ class PluginAdapter( // Clear glide image because setImageResource doesn't override override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is PluginViewHolder) { - holder.binding.entryIcon.let { pluginIcon -> - com.bumptech.glide.Glide.with(pluginIcon).clear(pluginIcon) - } + holder.itemView.entry_icon?.let { pluginIcon -> + GlideApp.with(pluginIcon).clear(pluginIcon) } super.onViewRecycled(holder) } @@ -103,8 +95,6 @@ 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)) @@ -122,11 +112,14 @@ class PluginAdapter( fun prettyCount(number: Number): String? { val suffix = charArrayOf(' ', 'k', 'M', 'B', 'T', 'P', 'E') val numValue = number.toLong() - val value = floor(log10(numValue.toDouble())).toInt() + val value = Math.floor(Math.log10(numValue.toDouble())).toInt() val base = value / 3 return if (value >= 3 && base < suffix.size) { DecimalFormat("#0.00").format( - numValue / 10.0.pow((base * 3).toDouble()) + numValue / Math.pow( + 10.0, + (base * 3).toDouble() + ) ) + suffix[base] } else { DecimalFormat().format(numValue) @@ -134,10 +127,9 @@ class PluginAdapter( } } - inner class PluginViewHolder(val binding: RepositoryItemBinding) : - RecyclerView.ViewHolder(binding.root) { + inner class PluginViewHolder(itemView: View) : + RecyclerView.ViewHolder(itemView) { - @SuppressLint("SetTextI18n") fun bind( data: PluginViewData, ) { @@ -146,17 +138,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") - binding.mainText.alpha = alpha - binding.subText.alpha = alpha + itemView.main_text?.alpha = alpha + itemView.sub_text?.alpha = alpha val drawableInt = if (data.isDownloaded) R.drawable.ic_baseline_delete_outline_24 else R.drawable.netflix_download - binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false - binding.actionButton.setImageResource(drawableInt) + itemView.nsfw_marker?.isVisible = metadata.tvTypes?.contains("NSFW") ?: false + itemView.action_button?.setImageResource(drawableInt) - binding.actionButton.setOnClickListener { + itemView.action_button?.setOnClickListener { iconClickCallback.invoke(data.plugin) } itemView.setOnClickListener { @@ -177,11 +169,10 @@ 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) { - binding.actionSettings.isVisible = true - binding.actionSettings.setOnClickListener { + itemView.action_settings?.isVisible = true + itemView.action_settings.setOnClickListener { try { plugin.openSettings!!.invoke(itemView.context) } catch (e: Throwable) { @@ -194,13 +185,13 @@ class PluginAdapter( } } } else { - binding.actionSettings.isVisible = false + itemView.action_settings?.isVisible = false } } else { - binding.actionSettings.isVisible = false + itemView.action_settings?.isVisible = false } - if (!binding.entryIcon.setImage(//itemView.entry_icon?.height ?: + if (itemView.entry_icon?.setImage(//itemView.entry_icon?.height ?: metadata.iconUrl?.replace( "%size%", "$iconSize" @@ -210,47 +201,41 @@ class PluginAdapter( ), null, errorImageDrawable = R.drawable.ic_baseline_extension_24 - ) + ) != true ) { - binding.entryIcon.setImageResource(R.drawable.ic_baseline_extension_24) + itemView.entry_icon?.setImageResource(R.drawable.ic_baseline_extension_24) } - binding.extVersion.isVisible = true - binding.extVersion.text = "v${metadata.version}" + itemView.ext_version?.isVisible = true + itemView.ext_version?.text = "v${metadata.version}" if (metadata.language.isNullOrBlank()) { - binding.langIcon.isVisible = false + itemView.lang_icon?.isVisible = false } else { - binding.langIcon.isVisible = true - binding.langIcon.text = - "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + itemView.lang_icon?.isVisible = true + itemView.lang_icon.text = "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" } - binding.extVotes.isVisible = false + itemView.ext_votes?.isVisible = false if (!isLocal) { ioSafe { metadata.getVotes().main { - binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(it))) - binding.extVotes.isVisible = true + itemView.ext_votes?.setText(txt(R.string.extension_rating, prettyCount(it))) + itemView.ext_votes?.isVisible = true } } } if (metadata.fileSize != null) { - binding.extFilesize.isVisible = true - binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) + itemView.ext_filesize?.isVisible = true + itemView.ext_filesize?.text = formatShortFileSize(itemView.context, metadata.fileSize) } else { - binding.extFilesize.isVisible = false + itemView.ext_filesize?.isVisible = false } - 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() + 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() } } } 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 7d733be0..9729b4de 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,28 +2,30 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList import android.os.Bundle -import android.text.format.Formatter.formatFileSize -import android.util.Log +import com.google.android.material.bottomsheet.BottomSheetDialogFragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.isVisible -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.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.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 com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.UIHelper.toPx +import kotlinx.android.synthetic.main.repository_item.view.* class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragment() { @@ -41,126 +43,116 @@ 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 { - val localBinding = FragmentPluginDetailsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_plugin_details, container, false) + ): View? { + 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 - 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) + 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) getString(R.string.no_data) - else + else "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" - githubBtn.setOnClickListener { - if (metadata.repositoryUrl != null) { - openBrowser(metadata.repositoryUrl) - } + github_btn.setOnClickListener { + if (metadata.repositoryUrl != null) { + openBrowser(metadata.repositoryUrl) } + } - if (!metadata.canVote()) { - upvote.alpha = .6f - } + if (!metadata.canVote()) { + downvote.alpha = .6f + 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) { - 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) - }" - ) - } + 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) + }" + ) } - } else { - actionSettings.isVisible = false } } else { - actionSettings.isVisible = false - } - - upvote.setOnClickListener { - ioSafe { - metadata.vote().main { - updateVoting(it) - } - } + action_settings?.isVisible = false } + } else { + action_settings?.isVisible = false + } + upvote.setOnClickListener { ioSafe { - metadata.getVotes().main { + metadata.vote(VotingApi.VoteType.UPVOTE).main { updateVoting(it) } } } + downvote.setOnClickListener { + ioSafe { + metadata.vote(VotingApi.VoteType.DOWNVOTE).main { + updateVoting(it) + } + + } + } + + ioSafe { + metadata.getVotes().main { + updateVoting(it) + } + } } private fun updateVoting(value: Int) { val metadata = data.plugin.second - 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 - ) + 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) } } } 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 4878049b..d328d226 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,25 +8,21 @@ 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.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.isTvSettings 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" @@ -37,19 +33,11 @@ class PluginsFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): 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() + ): View? { + return inflater.inflate(R.layout.fragment_plugins, container, false) } private val pluginViewModel: PluginsViewModel by activityViewModels() - var binding: FragmentPluginsBinding? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -71,134 +59,111 @@ 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?.onBackPressedDispatcher?.onBackPressed() + activity?.onBackPressed() return } - setToolBarScrollFlags() setUpToolbar(name) - 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() + 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() } - - else -> {} } - return@setOnMenuItemClickListener true + else -> {} } - - val searchView = - menu?.findItem(R.id.search_button)?.actionView as? SearchView - - // 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 - } - }) + return@setOnMenuItemClickListener true } + + val searchView = + settings_toolbar?.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() + } + } + // searchView?.onActionViewCollapsed = { // pluginViewModel.search(null) // } // Because onActionViewCollapsed doesn't wanna work we need this workaround :( + searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) pluginViewModel.search(null) + } - binding?.pluginRecyclerView?.setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + pluginViewModel.search(query) + return true + } - binding?.pluginRecyclerView?.adapter = + override fun onQueryTextChange(newText: String?): Boolean { + pluginViewModel.search(newText) + return true + } + }) + + + plugin_recycler_view?.adapter = PluginAdapter { pluginViewModel.handlePluginAction(activity, url, it, isLocal) } - if (isLayout(TV or EMULATOR)) { + if (isTvSettings()) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - binding?.pluginRecyclerView?.setPadding(0, 0, 0, 200.toPx) + plugin_recycler_view?.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (binding?.pluginRecyclerView?.adapter as? PluginAdapter)?.updateList(list) + (plugin_recycler_view?.adapter as? PluginAdapter)?.updateList(list) if (scrollToTop) - binding?.pluginRecyclerView?.scrollToPosition(0) + plugin_recycler_view?.scrollToPosition(0) } if (isLocal) { // No download button and no categories on local - downloadAllButton?.isVisible = false - binding?.settingsToolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false + settings_toolbar?.menu?.findItem(R.id.download_all)?.isVisible = false + settings_toolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() - - binding?.tvtypesChipsScroll?.root?.isVisible = false + tv_types_scroll_view?.isVisible = false } else { pluginViewModel.updatePluginList(context, url) - binding?.tvtypesChipsScroll?.root?.isVisible = true - // not needed for users but may be useful for devs - downloadAllButton?.isVisible = BuildConfig.DEBUG + tv_types_scroll_view?.isVisible = true - - - 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, - ) + bindChips(home_select_group, emptyList(), TvType.values().toList()) { list -> + pluginViewModel.tvTypes.clear() + pluginViewModel.tvTypes.addAll(list.map { it.name }) + pluginViewModel.updateFilteredPlugins() + } } } 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 fd5422b2..934f65bb 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,11 +8,9 @@ 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 @@ -88,18 +86,14 @@ class PluginsViewModel : ViewModel() { }.also { list -> main { showToast( - when { - // No plugins at all - plugins.isEmpty() -> txt( - R.string.no_plugins_found_error, - ) - // All plugins downloaded - list.isEmpty() -> txt( + activity, + if (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) @@ -119,6 +113,7 @@ class PluginsViewModel : ViewModel() { }.main { list -> if (list.any { it }) { showToast( + activity, txt( R.string.batch_download_finish_format, list.count { it }, @@ -128,7 +123,7 @@ class PluginsViewModel : ViewModel() { ) viewModel?.updatePluginListPrivate(activity, repositoryUrl) } else if (list.isNotEmpty()) { - showToast(R.string.download_failed, Toast.LENGTH_SHORT) + showToast(activity, R.string.download_failed, Toast.LENGTH_SHORT) } } } @@ -163,7 +158,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.internalName, + metadata.name, repo, isEnabled ) to message @@ -171,9 +166,9 @@ class PluginsViewModel : ViewModel() { runOnMainThread { if (success) - showToast(message, Toast.LENGTH_SHORT) + showToast(activity, message, Toast.LENGTH_SHORT) else - showToast(R.string.error, Toast.LENGTH_SHORT) + showToast(activity, R.string.error, Toast.LENGTH_SHORT) } if (success) @@ -184,15 +179,8 @@ 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.filter { - // Show all non-nsfw plugins or all if nsfw is enabled - it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult - }.map { plugin -> + val list = plugins.map { plugin -> PluginViewData(plugin, isDownloaded(context, plugin.second.internalName, plugin.first)) } @@ -207,7 +195,7 @@ class PluginsViewModel : ViewModel() { if (tvTypes.isEmpty()) return this return this.filter { (it.plugin.second.tvTypes?.any { type -> tvTypes.contains(type) } == true) || - (tvTypes.contains(TvType.Others.name) && (it.plugin.second.tvTypes + (tvTypes.contains("Others") && (it.plugin.second.tvTypes ?: emptyList()).isEmpty()) } } @@ -267,4 +255,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 faf6d38b..e90166a8 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,18 +1,14 @@ 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.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 +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import kotlinx.android.synthetic.main.repository_item.view.* class RepoAdapter( val isSetup: Boolean, @@ -24,17 +20,9 @@ class RepoAdapter( private val repositories: MutableList = mutableListOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - 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 + val layout = if(isTrueTvSettings()) R.layout.repository_item_tv else R.layout.repository_item return RepoViewHolder( - layout + LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } @@ -69,64 +57,30 @@ class RepoAdapter( diffResult.dispatchUpdatesTo(this) } - inner class RepoViewHolder( - val binding: ViewBinding - ) : - RecyclerView.ViewHolder(binding.root) { + inner class RepoViewHolder(itemView: View) : + RecyclerView.ViewHolder(itemView) { 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) - } - actionButton.setOnClickListener { - imageClickCallback(repositoryData) - } - - repositoryItemRoot.setOnClickListener { - clickCallback(repositoryData) - } - mainText.text = repositoryData.name - subText.text = repositoryData.url - } - } - - 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 - } - } + // 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) } + + itemView.action_button?.setOnClickListener { + imageClickCallback(repositoryData) + } + + itemView.repository_item_root?.setOnClickListener { + clickCallback(repositoryData) + } + 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 deleted file mode 100644 index 7878afaa..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index bad58a0e..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index eea495a2..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 818f1fd7..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -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 4369b22f..b7d2fff6 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,16 +8,21 @@ 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() { @@ -34,24 +39,13 @@ class SetupFragmentExtensions : Fragment() { } } - var binding: FragmentSetupExtensionsBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val localBinding = FragmentSetupExtensionsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_extensions, container, false) + ): View? { + return inflater.inflate(R.layout.fragment_setup_extensions, container, false) } - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories @@ -66,12 +60,12 @@ class SetupFragmentExtensions : Fragment() { main { val repositories = RepositoryManager.getRepositories() + PREBUILT_REPOSITORIES val hasRepos = repositories.isNotEmpty() - binding?.repoRecyclerView?.isVisible = hasRepos - binding?.blankRepoScreen?.isVisible = !hasRepos + repo_recycler_view?.isVisible = hasRepos + blank_repo_screen?.isVisible = !hasRepos // view_public_repositories_button?.isVisible = hasRepos if (hasRepos) { - binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { + repo_recycler_view?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) }).apply { updateList(repositories) } } @@ -86,40 +80,39 @@ class SetupFragmentExtensions : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) + context?.fixPaddingStatusbar(setup_root) val isSetup = arguments?.getBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP) ?: false // view_public_repositories_button?.setOnClickListener { // openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) // } - normalSafeApiCall { - // val ctx = context ?: return@normalSafeApiCall + with(context) { + if (this == null) return setRepositories() - binding?.apply { - if (!isSetup) { - nextBtt.setText(R.string.setup_done) - } - prevBtt.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) - } + if (!isSetup) { + next_btt.setText(R.string.setup_done) + } + prev_btt?.isVisible = isSetup - prevBtt.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_language) - } + 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) } } } 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 5c473b73..80db59ee 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,49 +13,40 @@ 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 { - val localBinding = FragmentSetupLanguageBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_language, container, false) + ): View? { + // Inflate the layout for this fragment + 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 { - fixPaddingStatusbar(binding?.setupRoot) + with(context) { + if (this == null) return@normalSafeApiCall + 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) - val arrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - - binding?.apply { // Icons may crash on some weird android versions? normalSafeApiCall { val drawable = when { @@ -63,10 +54,10 @@ class SetupFragmentLanguage : Fragment() { BuildConfig.BUILD_TYPE == "prerelease" -> R.drawable.cloud_2_gradient_beta else -> R.drawable.cloud_2_gradient } - appIconImage.setImageDrawable(ContextCompat.getDrawable(ctx, drawable)) + app_icon_image?.setImageDrawable(ContextCompat.getDrawable(this, drawable)) } - val current = getCurrentLocale(ctx) + val current = getCurrentLocale(this) val languageCodes = appLanguages.map { it.third } val languageNames = appLanguages.map { (emoji, name, iso) -> val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } @@ -75,19 +66,18 @@ 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() } - nextBtt.setOnClickListener { + next_btt?.setOnClickListener { // If no plugins go to plugins page val nextDestination = if ( PluginManager.getPluginsOnline().isEmpty() @@ -102,11 +92,10 @@ class SetupFragmentLanguage : Fragment() { ) } - skipBtt.setOnClickListener { + skip_btt?.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 d8fa46e6..50fb37d6 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,41 +9,31 @@ 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 { - val localBinding = FragmentSetupLayoutBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_layout, container, false) + ): View? { + return inflater.inflate(R.layout.fragment_setup_layout, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) + context?.fixPaddingStatusbar(setup_root) - normalSafeApiCall { - val ctx = context ?: return@normalSafeApiCall - - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + with(context) { + if (this == null) return + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) @@ -52,49 +42,48 @@ class SetupFragmentLayout : Fragment() { settingsManager.getInt(getString(R.string.app_layout_key), -1) val arrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + ArrayAdapter(this, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) - binding?.apply { - listview1.adapter = arrayAdapter - listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked( - prefValues.indexOf(currentLayout), true + 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 ) - 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) - } - val enableCrashReporting = !settingsManager.getBoolean(ACRA.PREF_DISABLE_ACRA, true) + next_btt?.setOnClickListener { + findNavController().navigate(R.id.navigation_home) + } - 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() - } + prev_btt?.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 49a93608..257ce5c1 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,82 +12,70 @@ import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API 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 { - val localBinding = FragmentSetupMediaBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_media, container, false) + ): View? { + return inflater.inflate(R.layout.fragment_setup_media, container, false) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - normalSafeApiCall { - fixPaddingStatusbar(binding?.setupRoot) + context?.fixPaddingStatusbar(setup_root) - val ctx = context ?: return@normalSafeApiCall - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + with(context) { + if (this == null) return + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val arrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + ArrayAdapter(this, R.layout.sort_bottom_single_choice) val names = enumValues().sorted().map { it.name } val selected = mutableListOf() arrayAdapter.addAll(names) - binding?.apply { - listview1.let { - it.adapter = arrayAdapter - it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + 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() - nextBtt.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) + // Regenerate set homepage + removeKey(USER_SELECTED_HOMEPAGE_API) } + } - prevBtt.setOnClickListener { - findNavController().popBackStack() - } + next_btt?.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) + } + + prev_btt?.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 c12e9eb8..51abee90 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,48 +11,36 @@ 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 { - val localBinding = FragmentSetupProviderLanguagesBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_provider_languages, container, false) + ): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_setup_provider_languages, container, false) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) + context?.fixPaddingStatusbar(setup_root) - normalSafeApiCall { - val ctx = context ?: return@normalSafeApiCall - - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + with(context) { + if (this == null) return + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val arrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + ArrayAdapter(this, R.layout.sort_bottom_single_choice) - val current = ctx.getApiProviderLangSettings() - val langs = synchronized(APIHolder.apis) { APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName} + val current = this.getApiProviderLangSettings() + val langs = APIHolder.apis.map { it.lang }.toSet() + .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName val currentList = current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO @@ -68,31 +56,31 @@ class SetupFragmentProviderLanguage : Fragment() { } arrayAdapter.addAll(languageNames) - binding?.apply { - listview1.adapter = arrayAdapter - listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + + 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( - ctx.getString(R.string.provider_lang_key), + this.getString(R.string.provider_lang_key), currentLanguages.toSet() ).apply() } - nextBtt.setOnClickListener { + next_btt?.setOnClickListener { findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) } - prevBtt.setOnClickListener { + prev_btt?.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 c76a218e..83d134cb 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,23 +13,17 @@ 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.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.google.android.exoplayer2.text.Cue +import com.google.android.gms.cast.TextTrackStyle +import com.google.android.gms.cast.TextTrackStyle.* 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.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.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -37,6 +31,7 @@ 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" @@ -45,12 +40,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 = EDGE_TYPE_OUTLINE, + @JsonProperty("edgeType") var edgeType: Int = TextTrackStyle.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() @@ -102,7 +97,7 @@ class ChromecastSubtitlesFragment : Fragment() { } private fun onColorSelected(stuff: Pair) { - setColor(stuff.first, stuff.second) + context?.setColor(stuff.first, stuff.second) if (hide) activity?.hideSystemUI() } @@ -125,7 +120,7 @@ class ChromecastSubtitlesFragment : Fragment() { return if (color == Color.TRANSPARENT) Color.BLACK else color } - private fun setColor(id: Int, color: Int?) { + private fun Context.setColor(id: Int, color: Int?) { val realColor = color ?: getDefColor(id) when (id) { 0 -> state.foregroundColor = realColor @@ -138,25 +133,16 @@ class ChromecastSubtitlesFragment : Fragment() { updateState() } - private fun updateState() { + private fun Context.updateState() { //subtitle_text?.setStyle(fromSaveToStyle(state)) } - var binding : ChromecastSubtitleSettingsBinding? = null - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): 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() + ): View? { + return inflater.inflate(R.layout.chromecast_subtitle_settings, container, false) } private lateinit var state: SaveChromeCaptionStyle @@ -173,12 +159,12 @@ class ChromecastSubtitlesFragment : Fragment() { onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - fixPaddingStatusbar(binding?.subsRoot) + context?.fixPaddingStatusbar(subs_root) state = getCurrentSavedStyle() - updateState() + context?.updateState() - val isTvSettings = isLayout(TV or EMULATOR) + val isTvSettings = isTvSettings() fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvSettings @@ -198,26 +184,23 @@ class ChromecastSubtitlesFragment : Fragment() { } this.setOnLongClickListener { - setColor(id, null) - showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + it.context.setColor(id, null) + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } - binding?.apply { - subsTextColor.setup(0) - subsOutlineColor.setup(1) - subsBackgroundColor.setup(2) - } - + subs_text_color.setup(0) + subs_outline_color.setup(1) + subs_background_color.setup(2) val dismissCallback = { if (hide) activity?.hideSystemUI() } - binding?.subsEdgeType?.setFocusableInTv() - binding?.subsEdgeType?.setOnClickListener { textView -> + subs_edge_type.setFocusableInTv() + subs_edge_type.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -250,19 +233,19 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.edgeType = edgeTypes.map { it.first }[index] - updateState() + textView.context.updateState() } } - binding?.subsEdgeType?.setOnLongClickListener { + subs_edge_type.setOnLongClickListener { state.edgeType = defaultState.edgeType - updateState() - showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + it.context.updateState() + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding?.subsFontSize?.setFocusableInTv() - binding?.subsFontSize?.setOnClickListener { textView -> + subs_font_size.setFocusableInTv() + subs_font_size.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -295,26 +278,24 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.subsFontSize?.setOnLongClickListener { _ -> + subs_font_size.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed - showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - - - binding?.subsFont?.setFocusableInTv() - binding?.subsFont?.setOnClickListener { textView -> + subs_font.setFocusableInTv() + subs_font.setOnClickListener { textView -> val fontTypes = listOf( - 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", + 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"), ) //showBottomDialog @@ -326,38 +307,38 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.fontFamily = fontTypes.map { it.first }[index] - updateState() + textView.context.updateState() } } - binding?.subsFont?.setOnLongClickListener { _ -> + + subs_font.setOnLongClickListener { textView -> state.fontFamily = defaultState.fontFamily - updateState() + textView.context.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding?.cancelBtt?.setOnClickListener { + cancel_btt.setOnClickListener { activity?.popCurrentPage() } - binding?.applyBtt?.setOnClickListener { + apply_btt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } - 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() - ) + + 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() ) - } + ) } } 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 8821905e..ff0e0e82 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,14 +14,12 @@ 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 androidx.media3.common.text.Cue -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.CaptionStyleCompat +import com.google.android.exoplayer2.text.Cue +import com.google.android.exoplayer2.ui.CaptionStyleCompat import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -29,9 +27,7 @@ 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.databinding.SubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -41,13 +37,15 @@ 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 @OptIn(UnstableApi::class) constructor( +data class SaveCaptionStyle( @JsonProperty("foregroundColor") var foregroundColor: Int, @JsonProperty("backgroundColor") var backgroundColor: Int, @JsonProperty("windowColor") var windowColor: Int, @@ -68,7 +66,6 @@ data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( const val DEF_SUBS_ELEVATION = 20 -@OptIn(androidx.media3.common.util.UnstableApi::class) class SubtitlesFragment : Fragment() { companion object { val applyStyleEvent = Event() @@ -168,7 +165,7 @@ class SubtitlesFragment : Fragment() { activity?.hideSystemUI() } - private fun onDialogDismissed(@Suppress("UNUSED_PARAMETER") id: Int) { + private fun onDialogDismissed(id: Int) { if (hide) activity?.hideSystemUI() } @@ -187,10 +184,10 @@ class SubtitlesFragment : Fragment() { } private fun Context.updateState() { - binding?.subtitleText?.setStyle(fromSaveToStyle(state)) - val text = getString(R.string.subtitles_example_text) + subtitle_text?.setStyle(fromSaveToStyle(state)) + val text = subtitle_text.context.getString(R.string.subtitles_example_text) val fixedText = if (state.upperCase) text.uppercase() else text - binding?.subtitleText?.setCues( + subtitle_text?.setCues( listOf( Cue.Builder() .setTextSize( @@ -216,21 +213,12 @@ 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 { - val localBinding = SubtitleSettingsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.subtitle_settings, container, false) + savedInstanceState: Bundle?, + ): View? { + return inflater.inflate(R.layout.subtitle_settings, container, false) } private lateinit var state: SaveCaptionStyle @@ -246,16 +234,16 @@ class SubtitlesFragment : Fragment() { hide = arguments?.getBoolean("hide") ?: true onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - binding?.subsImportText?.text = getString(R.string.subs_import_text).format( + subs_import_text?.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) - fixPaddingStatusbar(binding?.subsRoot) + context?.fixPaddingStatusbar(subs_root) state = getCurrentSavedStyle() context?.updateState() - val isTvTrueSettings = isLayout(TV) + val isTvTrueSettings = isTrueTvSettings() fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings @@ -276,318 +264,317 @@ class SubtitlesFragment : Fragment() { this.setOnLongClickListener { it.context.setColor(id, null) - showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + showToast(activity, 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) - val dismissCallback = { + 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() if (hide) activity?.hideSystemUI() } + } - 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_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 + } - //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() - } - } + 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) + ), + ) - 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 + //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() - 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_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 + } - 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_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 } + textView.context.updateState() } + } - subsAutoSelectLanguage.setOnLongClickListener { - setKey(SUBTITLE_AUTO_SELECT_KEY, "en") - 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 + } + + 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]) } + } - 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 } + 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 + } - activity?.showMultiDialog( - langMap.map { it.languageName }, - keyMap, - (textView as TextView).text.toString(), - dismissCallback - ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) - } + 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()) } + } - subsDownloadLanguages.setOnLongClickListener { - setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) + subs_download_languages.setOnLongClickListener { + setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } - cancelBtt.setOnClickListener { - activity?.popCurrentPage() - } + cancel_btt.setOnClickListener { + activity?.popCurrentPage() + } - applyBtt.setOnClickListener { - it.context.saveStyle(state) - applyStyleEvent.invoke(state) - it.context.fromSaveToStyle(state) - activity?.popCurrentPage() - } + apply_btt.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 f0c948a4..e9b69c5b 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/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt similarity index 71% rename from app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt index 8d65acf7..00dee9b2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -1,11 +1,8 @@ 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 @@ -20,10 +17,6 @@ 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 @@ -32,17 +25,16 @@ 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 @@ -50,20 +42,17 @@ 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.APP_STRING_RESUME_WATCHING -import com.lagradost.cloudstream3.syncproviders.providers.Kitsu +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching 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.Globals -import com.lagradost.cloudstream3.ui.settings.extensions.PluginsFragment +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.extensions.RepositoryData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main @@ -78,8 +67,9 @@ import okhttp3.Cache import java.io.* import java.net.URL import java.net.URLDecoder +import kotlin.system.measureTimeMillis -object AppContextUtils { +object AppUtils { fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { for (i in 0..maxViewTypeId) recycledViewPool.setMaxRecycledViews(i, maxPoolSize) @@ -92,9 +82,6 @@ object AppContextUtils { 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() } @@ -160,7 +147,7 @@ object AppContextUtils { .setTitle(title) .setPosterArtUri(Uri.parse(card.posterUrl)) .setIntentUri(Uri.parse(card.id?.let { - "$APP_STRING_RESUME_WATCHING://$it" + "$appStringResumeWatching://$it" } ?: card.url)) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( @@ -192,40 +179,6 @@ object AppContextUtils { 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 @@ -300,7 +253,6 @@ object AppContextUtils { // 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 @@ -371,168 +323,6 @@ object AppContextUtils { } } - 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 @@ -544,12 +334,13 @@ object AppContextUtils { ) main { showToast( + this@loadRepository, getString(R.string.player_loaded_subtitles, repo.name), Toast.LENGTH_LONG ) } afterRepositoryLoadedEvent.invoke(true) - addRepositoryDialog(repo.name, url) + downloadAllPluginsDialog(url, repo.name) } } @@ -592,36 +383,25 @@ object AppContextUtils { } } - fun Activity.addRepositoryDialog( - repositoryName: String, - repositoryURL: String, - ) { - val repos = RepositoryManager.getRepositories() - - // 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, - ) - ) - } - } + fun Activity.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) { runOnUiThread { - AlertDialog.Builder(this).apply { - setTitle(repositoryName) - setMessage(R.string.download_all_plugins_from_repo) - setPositiveButton(R.string.open_downloaded_repo) { _, _ -> - openAddedRepo() + 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.dismiss, null) - show().setDefaultFocus() + + setNegativeButton(R.string.no) { _, _ -> } } + builder.show().setDefaultFocus() } } @@ -676,18 +456,6 @@ object AppContextUtils { } } - 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 @@ -700,6 +468,24 @@ object AppContextUtils { 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 @@ -743,36 +529,19 @@ object AppContextUtils { //private val viewModel: ResultViewModel by activityViewModels() private fun getResultsId(): Int { - return if (Globals.isLayout(Globals.TV or Globals.EMULATOR)) { + return if (isTrueTvSettings()) { 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( @@ -782,14 +551,6 @@ object AppContextUtils { } } - fun loadSearchResult( - card: SearchResponse, - startAction: Int = 0, - startValue: Int? = null, - ) { - activity?.loadSearchResult(card, startAction, startValue) - } - fun Activity?.loadSearchResult( card: SearchResponse, startAction: Int = 0, @@ -867,7 +628,7 @@ object AppContextUtils { * Sets the focus to the negative button when in TV and Emulator layout. **/ fun AlertDialog.setDefaultFocus(buttonFocus: Int = DialogInterface.BUTTON_NEGATIVE) { - if (!Globals.isLayout(Globals.TV or Globals.EMULATOR)) return + if (!isTvSettings()) return this.getButton(buttonFocus).run { isFocusableInTouchMode = true requestFocus() @@ -974,14 +735,9 @@ object AppContextUtils { 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 deleted file mode 100644 index 1326ab27..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ /dev/null @@ -1,30 +0,0 @@ -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 b25be59f..2318fda6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,8 +1,11 @@ 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 @@ -10,7 +13,6 @@ 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 @@ -26,22 +28,21 @@ 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.setupStream -import okhttp3.internal.closeQuietly -import java.io.OutputStream +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.VideoDownloadManager.isDownloadDir +import java.io.IOException import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat -import java.util.Date +import java.util.* object BackupUtils { @@ -65,28 +66,24 @@ 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 key should not be contained in backup */ + /** false if blacklisted key */ private fun String.isTransferable(): Boolean { - return !nonTransferableKeys.any { this.contains(it) } + return !nonTransferableKeys.contains(this) } 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( @@ -95,11 +92,9 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - 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() } + fun Context.getBackup(): BackupFile { + val allData = getSharedPrefs().all.filter { it.key.isTransferable() } + val allSettings = getDefaultSharedPrefs().all.filter { it.key.isTransferable() } val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, @@ -126,56 +121,87 @@ object BackupUtils { } @WorkerThread - fun restore( - context: Context?, + fun Context.restore( backupFile: BackupFile, restoreSettings: Boolean, restoreDataStore: Boolean ) { - if (context == null) return if (restoreSettings) { - 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) + 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) } if (restoreDataStore) { - 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) + restoreMap(backupFile.datastore._Bool) + restoreMap(backupFile.datastore._Int) + restoreMap(backupFile.datastore._String) + restoreMap(backupFile.datastore._Float) + restoreMap(backupFile.datastore._Long) + restoreMap(backupFile.datastore._StringSet) } } @SuppressLint("SimpleDateFormat") - fun backup(context: Context?) = ioSafe { - if (context == null) return@ioSafe - - var fileStream: OutputStream? = null - var printStream: PrintWriter? = null + fun FragmentActivity.backup() { try { - if (!context.checkWrite()) { - showToast(R.string.backup_failed, Toast.LENGTH_LONG) - context.getActivity()?.requestRW() - return@ioSafe + if (!checkWrite()) { + showToast(this, getString(R.string.backup_failed), Toast.LENGTH_LONG) + requestRW() + return } + val subDir = getBasePath().first val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) - val ext = "txt" + val ext = "json" val displayName = "CS3_Backup_${date}" - val backupFile = getBackup(context) - val stream = setupStream(context, displayName, null, ext, false) + val backupFile = getBackup() - fileStream = stream.openNew() - printStream = PrintWriter(fileStream) + 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) printStream.print(mapper.writeValueAsString(backupFile)) + printStream.close() showToast( + this, R.string.backup_success, Toast.LENGTH_LONG ) @@ -183,15 +209,13 @@ object BackupUtils { logError(e) try { showToast( - txt(R.string.backup_failed_error_format, e.toString()), + this, + getString(R.string.backup_failed_error_format).format(e.toString()), Toast.LENGTH_LONG ) } catch (e: Exception) { logError(e) } - } finally { - printStream?.closeQuietly() - fileStream?.closeQuietly() } } @@ -209,8 +233,7 @@ object BackupUtils { val restoredValue = mapper.readValue(input) - restore( - activity, + activity.restore( restoredValue, restoreSettings = true, restoreDataStore = true @@ -220,6 +243,7 @@ object BackupUtils { logError(e) main { // smth can fail in .format showToast( + activity, getString(R.string.restore_failed_format).format(e.toString()) ) } @@ -246,7 +270,7 @@ object BackupUtils { ) ) } catch (e: Exception) { - showToast(e.message) + showToast(this, e.message) logError(e) } } @@ -256,12 +280,8 @@ object BackupUtils { map: Map?, isEditingAppSettings: Boolean = false ) { - val editor = DataStore.editor(this, isEditingAppSettings) - map?.forEach { - if (it.key.isTransferable()) { - editor.setKeyRaw(it.key, it.value) - } + map?.filter { it.key.isTransferable() }?.forEach { + setKeyRaw(it.key, it.value, isEditingAppSettings) } - 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 deleted file mode 100644 index 45acbab4..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt +++ /dev/null @@ -1,172 +0,0 @@ -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 d8373165..9e8cc1d4 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 androidx.media3.common.MimeTypes +import com.google.android.exoplayer2.util.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,11 +55,7 @@ object CastHelper { val builder = MediaInfo.Builder(link.url) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) - .setContentType(when(link.type) { - ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 - ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD - else -> MimeTypes.VIDEO_MP4 - }) + .setContentType(if (link.isM3u8) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4) .setMetadata(movieMetadata) .setMediaTracks(tracks) data?.let { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt similarity index 90% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt index f87ddc6a..c3b244c2 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt @@ -1,11 +1,12 @@ package com.lagradost.cloudstream3.utils +import android.os.Handler +import android.os.Looper import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import kotlinx.coroutines.* import java.util.Collections.synchronizedList -expect fun runOnMainThreadNative(work: (() -> Unit)) object Coroutines { fun T.main(work: suspend ((T) -> Unit)): Job { val value = this @@ -49,7 +50,10 @@ object Coroutines { } fun runOnMainThread(work: (() -> Unit)) { - runOnMainThreadNative(work) + val mainHandler = Handler(Looper.getMainLooper()) + mainHandler.post { + work() + } } /** 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 b5192aae..e1cedd39 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -5,13 +5,8 @@ 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.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass +import com.fasterxml.jackson.module.kotlin.KotlinModule import com.lagradost.cloudstream3.mvvm.logError -import kotlin.reflect.KClass -import kotlin.reflect.KProperty const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -23,68 +18,8 @@ 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 { @@ -99,10 +34,22 @@ object DataStore { return "${folder}/${path}" } - 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.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 Context.getDefaultSharedPrefs(): SharedPreferences { @@ -140,7 +87,7 @@ object DataStore { } fun Context.removeKeys(folder: String): Int { - val keys = getKeys("$folder/") + val keys = getKeys(folder) keys.forEach { value -> removeKey(value) } @@ -157,15 +104,6 @@ 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) } @@ -174,10 +112,6 @@ 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 2fa5f6a3..281c9c44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,42 +1,25 @@ package com.lagradost.cloudstream3.utils -import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.APIHolder.capitalize 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" @@ -44,153 +27,7 @@ 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 @@ -205,77 +42,19 @@ object DataStoreHelper { return this } - 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( + data class BookmarkedData( @JsonProperty("id") override var id: Int?, - @JsonProperty("latestUpdatedTime") open val latestUpdatedTime: Long, + @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, + @JsonProperty("latestUpdatedTime") 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?, + @JsonProperty("type") override var type: TvType? = null, @JsonProperty("posterUrl") override var posterUrl: String?, - @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) { + @JsonProperty("year") val year: Int?, + @JsonProperty("quality") override var quality: SearchQuality? = null, + @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, + ) : SearchResponse { fun toLibraryItem(id: String): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( name, @@ -284,39 +63,8 @@ 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, - null, - null, - latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags + apiName, type, posterUrl, posterHeaders, quality, this.id ) } } @@ -327,7 +75,9 @@ 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?, @@ -340,6 +90,7 @@ 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" @@ -353,11 +104,11 @@ object DataStoreHelper { removeKeys(folder) } - 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 deleteAllBookmarkedData() { + val folder1 = "$currentAccount/$RESULT_WATCH_STATE" + val folder2 = "$currentAccount/$RESULT_WATCH_STATE_DATA" + removeKeys(folder1) + removeKeys(folder2) } fun getAllResumeStateIds(): List? { @@ -453,70 +204,6 @@ 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 @@ -555,10 +242,12 @@ object DataStoreHelper { fun setResultWatchState(id: Int?, status: Int) { if (id == null) return + val folder = "$currentAccount/$RESULT_WATCH_STATE" if (status == WatchType.NONE.internalId) { - deleteBookmarkedData(id) + removeKey(folder, id.toString()) + removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } else { - setKey("$currentAccount/$RESULT_WATCH_STATE", id.toString(), status) + setKey(folder, 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 c92da214..c1eb649b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -15,7 +15,6 @@ 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" @@ -26,32 +25,28 @@ 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) + downloadCheck(applicationContext, ::handleNotification)?.let { + awaitDownload(it) + } } 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 - ) - + applicationContext.getKey(WORK_KEY_PACKAGE, key) 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 - ) - } + downloadEpisode( + applicationContext, + info.source, + info.folder, + info.ep, + info.links, + ::handleNotification + ) + awaitDownload(info.ep.id) } else if (pkg != null) { downloadFromResume(applicationContext, pkg, ::handleNotification) + awaitDownload(pkg.item.ep.id) } removeKeys(key) } @@ -78,7 +73,6 @@ 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 a0dfe734..26f83d1e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -3,24 +3,16 @@ 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) { - synchronized(observers) { - observers.add(observer) - } + observers.add(observer) } operator fun minusAssign(observer: (T) -> Unit) { - synchronized(observers) { - observers.remove(observer) - } + observers.remove(observer) } operator fun invoke(value: T) { - synchronized(observers) { - for (observer in observers) - observer(value) - } + 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 new file mode 100644 index 00000000..1ad3639b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -0,0 +1,488 @@ +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 38d3fe9e..4b0ee890 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt @@ -8,7 +8,6 @@ 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 @@ -28,10 +27,6 @@ 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 new file mode 100644 index 00000000..b4922945 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt @@ -0,0 +1,5 @@ +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 59f534ff..8b516e8c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -23,8 +23,7 @@ import okio.buffer import okio.sink import java.io.File import android.text.TextUtils -import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit -import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -32,26 +31,26 @@ import java.io.InputStreamReader class InAppUpdater { companion object { - private const val GITHUB_USER_NAME = "recloudstream" - private const val GITHUB_REPO = "cloudstream" + const val GITHUB_USER_NAME = "recloudstream" + const val GITHUB_REPO = "cloudstream" - private const val LOG_TAG = "InAppUpdater" + 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 browserDownloadUrl: String, // download link - @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive + @JsonProperty("browser_download_url") val browser_download_url: String, // download link + @JsonProperty("content_type") val content_type: String, // application/vnd.android.package-archive ) data class GithubRelease( - @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("tag_name") val tag_name: String, // Version code @JsonProperty("body") val body: String, // Desc @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val targetCommitish: String, // branch + @JsonProperty("target_commitish") val target_commitish: String, // branch @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val nodeId: String //Node Id + @JsonProperty("node_id") val node_id: String //Node Id ) data class GithubObject( @@ -61,7 +60,7 @@ class InAppUpdater { ) data class GithubTag( - @JsonProperty("object") val githubObject: GithubObject, + @JsonProperty("object") val github_object: GithubObject, ) data class Update( @@ -110,19 +109,18 @@ class InAppUpdater { releases.sortedWith(compareBy { versionRegex.find(it.name)?.groupValues?.get(2) }).toList().lastOrNull()*/ - val foundList = + val found = response.filter { rel -> !rel.prerelease }.sortedWith(compareBy { release -> - release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> + release.assets.filter { it.content_type == "application/vnd.android.package-archive" } + .getOrNull(0)?.name?.let { it1 -> versionRegex.find( it1 - )?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } + )?.groupValues?.get(2) } - }).toList() - val found = foundList.lastOrNull() + }).toList().lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) val currentVersion = packageName?.let { packageManager.getPackageInfo( @@ -134,7 +132,7 @@ class InAppUpdater { foundAsset?.name?.let { assetName -> val foundVersion = versionRegex.find(assetName) val shouldUpdate = - if (foundAsset.browserDownloadUrl != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> + if (foundAsset.browser_download_url != "" && 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() } @@ -146,10 +144,10 @@ class InAppUpdater { return if (foundVersion != null) { Update( shouldUpdate, - foundAsset.browserDownloadUrl, + foundAsset.browser_download_url, foundVersion.groupValues[2], found.body, - found.nodeId + found.node_id ) } else { Update(false, null, null, null, null) @@ -168,33 +166,33 @@ class InAppUpdater { val found = response.lastOrNull { rel -> - rel.prerelease || rel.tagName == "pre-release" + rel.prerelease || rel.tag_name == "pre-release" } val foundAsset = found?.assets?.filter { it -> - it.contentType == "application/vnd.android.package-archive" + it.content_type == "application/vnd.android.package-archive" }?.getOrNull(0) val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) - Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.githubObject.sha.take(7)}") + Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.github_object.sha.take(7)}") val shouldUpdate = (getString(R.string.commit_hash) .trim { c -> c.isWhitespace() } .take(7) != - tagResponse.githubObject.sha + tagResponse.github_object.sha .trim { c -> c.isWhitespace() } .take(7)) return if (foundAsset != null) { Update( shouldUpdate, - foundAsset.browserDownloadUrl, - tagResponse.githubObject.sha.take(10), + foundAsset.browser_download_url, + tagResponse.github_object.sha, found.body, - found.nodeId + found.node_id ) } else { Update(false, null, null, null, null) @@ -214,7 +212,7 @@ class InAppUpdater { this.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { - deleteFileOnExit(it) + it.deleteOnExit() } val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") @@ -294,13 +292,7 @@ class InAppUpdater { update.updateVersion ) ) - - val logRegex = Regex("\\[(.*?)\\]\\((.*?)\\)") - val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> - matchResult.groupValues[1] - } // Sanitized because it looks cluttered - - builder.setMessage(sanitizedChangelog) + builder.setMessage("${update.changelog}") val context = this builder.apply { @@ -308,7 +300,7 @@ class InAppUpdater { // Forcefully start any delayed installations if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton - showToast(R.string.download_started, Toast.LENGTH_LONG) + showToast(context, R.string.download_started, Toast.LENGTH_LONG) // Check if the setting hasn't been changed if (settingsManager.getInt( @@ -343,6 +335,7 @@ class InAppUpdater { if (!downloadUpdate(update.updateURL)) runOnUiThread { showToast( + context, R.string.download_failed, Toast.LENGTH_LONG ) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/JsHunter.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/JsHunter.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/JsUnpacker.kt similarity index 99% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/JsUnpacker.kt index d9f0b382..153dbd3e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/JsUnpacker.kt @@ -50,7 +50,7 @@ class JsUnpacker(packedJS: String?) { throw Exception("Unknown p.a.c.k.e.r. encoding") } val unbase = Unbase(radix) - p = Pattern.compile("""\b[a-zA-Z0-9_]+\b""") + p = Pattern.compile("\\b\\w+\\b") m = p.matcher(payload) val decoded = StringBuilder(payload) var replaceOffset = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt new file mode 100644 index 00000000..6c5117b4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -0,0 +1,272 @@ +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 4b3f02f1..bc81a5b9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -11,6 +11,7 @@ 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 @@ -56,7 +57,7 @@ class ApkInstaller(private val service: PackageInstallerService) { PackageInstaller.STATUS_FAILURE )) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val userAction = intent.getSafeParcelableExtra(Intent.EXTRA_INTENT) + val userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT) userAction?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(userAction) } @@ -145,5 +146,3 @@ 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 57b98dc2..1625981e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt @@ -1,21 +1,19 @@ 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 @@ -49,19 +47,27 @@ class PackageInstallerService : Service() { .setSmallIcon(R.drawable.rdload) } - override fun onCreate() { - 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 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()) + } + private val updateLock = Mutex() private suspend fun downloadUpdate(url: String): Boolean { @@ -76,7 +82,7 @@ class PackageInstallerService : Service() { this@PackageInstallerService.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { - deleteFileOnExit(it) + it.deleteOnExit() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt deleted file mode 100644 index 1e572fb7..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 0d3da8e7..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ /dev/null @@ -1,86 +0,0 @@ -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 70edf80c..2dc6846c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -2,31 +2,19 @@ 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.AbsListView -import android.widget.ArrayAdapter -import android.widget.LinearLayout -import android.widget.TextView +import android.widget.* import androidx.appcompat.app.AlertDialog -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 androidx.core.view.* import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.R -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.ui.settings.SettingsFragment.Companion.isTvSettings 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( @@ -56,15 +44,15 @@ object SingleSelectionHelper { ) { if (this == null) return - if (isLayout(TV or EMULATOR)) { - val binding = OptionsPopupTvBinding.inflate(layoutInflater) - val dialog = AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(binding.root) - .create() + if (isTvSettings()) { + val builder = + AlertDialog.Builder(this, R.style.AlertDialogCustom) + .setView(R.layout.options_popup_tv) + val dialog = builder.create() dialog.show() - binding.listview1.let { listView -> + dialog.findViewById(R.id.listview1)?.let { listView -> listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE listView.adapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice_color).apply { @@ -77,7 +65,7 @@ object SingleSelectionHelper { } } - binding.imageView.apply { + dialog.findViewById(R.id.imageView)?.apply { isGone = poster.isNullOrEmpty() setImage(poster) } @@ -94,7 +82,6 @@ object SingleSelectionHelper { } fun Activity?.showDialog( - binding: BottomSelectionDialogBinding, dialog: Dialog, items: List, selectedIndex: List, @@ -108,39 +95,39 @@ object SingleSelectionHelper { if (this == null) return val realShowApply = showApply || isMultiSelect - val listView = binding.listview1 - val textView = binding.text1 - val applyButton = binding.applyBtt - val cancelButton = binding.cancelBtt + 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 applyHolder = - binding.applyBttHolder + dialog.apply_btt_holder//.findViewById(R.id.apply_btt_holder) - 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 @@ -149,7 +136,7 @@ object SingleSelectionHelper { dismissCallback.invoke() } - listView.setOnItemClickListener { _, _, which, _ -> + listView?.setOnItemClickListener { _, _, which, _ -> // lastSelectedIndex = which if (realShowApply) { if (!isMultiSelect) { @@ -161,7 +148,7 @@ object SingleSelectionHelper { } } if (realShowApply) { - applyButton.setOnClickListener { + applyButton?.setOnClickListener { val list = ArrayList() for (index in 0 until listView.count) { if (listView.checkedItemPositions[index]) @@ -170,14 +157,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, @@ -187,11 +174,11 @@ object SingleSelectionHelper { ) { if (this == null) return - val inputView = binding.nginxTextInput - val textView = binding.text1 - val applyButton = binding.applyBtt - val cancelButton = binding.cancelBtt - val applyHolder = binding.applyBttHolder + 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)!! applyHolder.isVisible = true textView.text = name @@ -226,26 +213,13 @@ object SingleSelectionHelper { ) { if (this == null) return - val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( - LayoutInflater.from(this) - ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(binding.root) + .setView(R.layout.bottom_selection_dialog) val dialog = builder.create() dialog.show() - showDialog( - binding, - dialog, - items, - selectedIndex, - name, - showApply = true, - isMultiSelect = true, - callback, - dismissCallback - ) + showDialog(dialog, items, selectedIndex, name, true, true, callback, dismissCallback) } fun Activity?.showDialog( @@ -258,19 +232,13 @@ object SingleSelectionHelper { ) { if (this == null) return - val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( - LayoutInflater.from(this) - ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(binding.root) + .setView(R.layout.bottom_selection_dialog) val dialog = builder.create() dialog.show() - - showDialog( - binding, dialog, items, listOf(selectedIndex), @@ -282,6 +250,17 @@ 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, @@ -292,18 +271,12 @@ object SingleSelectionHelper { callback: (Int) -> Unit, ) { if (this == null) return - - val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( - LayoutInflater.from(this) - ) - val builder = BottomSheetDialog(this) - builder.setContentView(binding.root) + builder.setContentView(R.layout.bottom_selection_dialog) builder.show() showDialog( - binding, builder, items, listOf(selectedIndex), @@ -323,19 +296,13 @@ 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, - emptyList(), + listOf(), name, showApply = false, isMultiSelect = false, @@ -353,17 +320,11 @@ object SingleSelectionHelper { dismissCallback: () -> Unit, callback: (String) -> Unit, ) { - val builder = BottomSheetDialog(this) - - val binding: BottomInputDialogBinding = BottomInputDialogBinding.inflate( - LayoutInflater.from(this) - ) - - builder.setContentView(binding.root) + val builder = BottomSheetDialog(this) // probably the stuff at the bottom + builder.setContentView(R.layout.bottom_input_dialog) // input layout builder.show() showInputDialog( - binding, builder, value, name, @@ -372,24 +333,4 @@ 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 deleted file mode 100644 index e6a77795..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt +++ /dev/null @@ -1,84 +0,0 @@ -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 09f5e0f1..33f1b6ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt @@ -153,11 +153,9 @@ 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", @@ -517,4 +515,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 deleted file mode 100644 index 93a53395..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ /dev/null @@ -1,56 +0,0 @@ -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 351e77c8..e5f2f2dc 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,13 +44,6 @@ 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 - } - } } } } @@ -73,8 +66,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() @@ -96,10 +89,8 @@ object SyncUtil { .mapNotNull { it.url }.toMutableList() if (type == "anilist") { // TODO MAKE BETTER - synchronized(apis) { - apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { - current.add("${it.mainUrl}/anime/$id") - } + apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { + current.add("${it.mainUrl}/anime/$id") } } return current @@ -135,8 +126,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 deleted file mode 100644 index 049f92fb..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ /dev/null @@ -1,324 +0,0 @@ -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 ad1b6502..c300d615 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -5,8 +5,6 @@ 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 @@ -16,38 +14,25 @@ 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 @@ -59,33 +44,23 @@ 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.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 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 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 Context.checkWrite(): Boolean { + fun Activity.checkWrite(): Boolean { return (ContextCompat.checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE @@ -96,31 +71,6 @@ 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, @@ -133,35 +83,6 @@ 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. @@ -212,14 +133,6 @@ 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 { @@ -230,8 +143,9 @@ object UIHelper { fun Activity?.navigate(@IdRes navigation: Int, arguments: Bundle? = null) { try { if (this is FragmentActivity) { - val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment? - navHostFragment?.navController?.navigate(navigation, arguments) + (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.navController?.navigate( + navigation, arguments + ) } } catch (t: Throwable) { logError(t) @@ -266,93 +180,19 @@ 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 (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 + if (this == null || url.isNullOrBlank()) return false return try { - var builder = com.bumptech.glide.Glide.with(this) - .load(glideImage) + val builder = GlideApp.with(this) + .load(GlideUrl(url) { headers ?: emptyMap() }) .skipMemoryCache(true) .diskCacheStrategy(DiskCacheStrategy.ALL).let { req -> if (fadeIn) @@ -360,35 +200,26 @@ object UIHelper { else req } - if (radius > 0) { - builder = builder.apply(bitmapTransform(BlurTransformation(radius, sample))) - } - if (colorCallback != null) { - builder = builder.listener(object : RequestListener { - + builder.listener(object : RequestListener { + @SuppressLint("CheckResult") 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( - identifier, - bitmap, - colorCallback - ) - } + resource?.toBitmapOrNull() + ?.let { bitmap -> createPaletteAsync(url, bitmap, colorCallback) } return false } + @SuppressLint("CheckResult") override fun onLoadFailed( e: GlideException?, model: Any?, - target: Target, + target: Target?, isFirstResource: Boolean ): Boolean { return false @@ -417,7 +248,7 @@ object UIHelper { ) { if (this == null || url.isNullOrBlank()) return try { - val res = com.bumptech.glide.Glide.with(this) + val res = GlideApp.with(this) .load(GlideUrl(url) { headers ?: emptyMap() }) .apply(bitmapTransform(BlurTransformation(radius, sample))) .transition( @@ -451,53 +282,84 @@ 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 - /** BUGGED AF **/ - /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - - WindowCompat.setDecorFitsSystemWindows(window, false) - WindowInsetsControllerCompat(window, View(this)).let { controller -> - controller.hide(WindowInsetsCompat.Type.systemBars()) - controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - }*/ - - @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 - ) - //} + 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) } 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() + 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 { - // 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) + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + }*/ + + // 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 + ) + .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 Context.getStatusBarHeight(): Int { - if (isLayout(Globals.TV or EMULATOR)) { + if (isTvSettings()) { return 0 } @@ -509,38 +371,21 @@ object UIHelper { return result } - fun fixPaddingStatusbar(v: View?) { - if (v == null) return - val ctx = v.context ?: return + fun Context?.fixPaddingStatusbar(v: View?) { + if (v == null || this == null) return v.setPadding( v.paddingLeft, - v.paddingTop + ctx.getStatusBarHeight(), + v.paddingTop + getStatusBarHeight(), v.paddingRight, v.paddingBottom ) } - fun fixPaddingStatusbarMargin(v: View?) { + fun Context.fixPaddingStatusbarView(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 = ctx.getStatusBarHeight() + params.height = getStatusBarHeight() v.layoutParams = params } @@ -553,7 +398,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) @@ -561,26 +406,13 @@ object UIHelper { fun Activity.changeStatusBarState(hide: Boolean): Int { return if (hide) { - - 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 - ) - } + window?.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) 0 } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.insetsController?.show(WindowInsets.Type.statusBars()) - - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) - } - + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) this.getStatusBarHeight() } } @@ -588,18 +420,13 @@ 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 = - /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) - WindowCompat.setDecorFitsSystemWindows(window, true) - WindowInsetsControllerCompat(window, View(this)).show(WindowInsetsCompat.Type.systemBars()) + changeStatusBarState(isEmulatorSettings()) - } 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)) + // window.clearFlags(View.KEEP_SCREEN_ON) } fun Context.shouldShowPIPMode(isInPlayer: Boolean): Boolean { @@ -651,7 +478,7 @@ object UIHelper { } fun Dialog?.dismissSafe() { - if (this?.isShowing == true && activity?.isFinishing != true) { + if (this?.isShowing == true) { this.dismiss() } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UnshortenUrl.kt similarity index 96% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/UnshortenUrl.kt index b13e88e5..46b232f6 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UnshortenUrl.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.util.Base64 import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.base64Decode import com.lagradost.nicehttp.NiceResponse @@ -90,12 +91,13 @@ object ShortLink { } val encodedbytearray = encodedUri.map { it.code.toByte() }.toByteArray() var decodedUri = - base64Decode(encodedbytearray.toString()).dropLast(16) + Base64.decode(encodedbytearray, Base64.DEFAULT).decodeToString().dropLast(16) .drop(16) if (Regex("""go\.php\?u=""").find(decodedUri) != null) { decodedUri = - base64Decode(decodedUri.replace(Regex("""(.*?)u="""), "")) + Base64.decode(decodedUri.replace(Regex("""(.*?)u="""), ""), Base64.DEFAULT) + .decodeToString() } return decodedUri 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 30f66f83..a76cc115 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -2,22 +2,20 @@ package com.lagradost.cloudstream3.utils import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType -object VideoDownloadHelper { - abstract class DownloadCached( - @JsonProperty("id") open val id: Int, - ) +import com.lagradost.cloudstream3.ui.download.EasyDownloadButton +object VideoDownloadHelper { 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, - override val id: Int, - ): DownloadCached(id) + ) : EasyDownloadButton.IMinimumData data class DownloadHeaderCached( @JsonProperty("apiName") val apiName: String, @@ -25,9 +23,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 2190e03f..a629dad9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -8,7 +8,10 @@ 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 @@ -17,50 +20,37 @@ 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.lagradost.api.Log +import com.hippo.unifile.UniFile 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.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.internal.closeQuietly -import java.io.Closeable +import java.io.BufferedInputStream import java.io.File -import java.io.IOException +import java.io.InputStream import java.io.OutputStream -import java.lang.IllegalArgumentException +import java.lang.Thread.sleep +import java.net.URI +import java.net.URL +import java.net.URLConnection import java.util.* +import kotlin.math.roundToInt const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" @@ -68,35 +58,34 @@ 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/116.0.0.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - @get:DrawableRes - val imgDone get() = R.drawable.rddone + @DrawableRes + const val imgDone = R.drawable.rddone - @get:DrawableRes - val imgDownloading get() = R.drawable.rdload + @DrawableRes + const val imgDownloading = R.drawable.rdload - @get:DrawableRes - val imgPaused get() = R.drawable.rdpause + @DrawableRes + const val imgPaused = R.drawable.rdpause - @get:DrawableRes - val imgStopped get() = R.drawable.rderror + @DrawableRes + const val imgStopped = R.drawable.rderror - @get:DrawableRes - val imgError get() = R.drawable.rderror + @DrawableRes + const val imgError = R.drawable.rderror - @get:DrawableRes - val pressToPauseIcon get() = R.drawable.ic_baseline_pause_24 + @DrawableRes + const val pressToPauseIcon = R.drawable.ic_baseline_pause_24 - @get:DrawableRes - val pressToResumeIcon get() = R.drawable.ic_baseline_play_arrow_24 + @DrawableRes + const val pressToResumeIcon = R.drawable.ic_baseline_play_arrow_24 - @get:DrawableRes - val pressToStopIcon get() = R.drawable.baseline_stop_24 + @DrawableRes + const val pressToStopIcon = R.drawable.exo_icon_stop enum class DownloadType { IsPaused, @@ -104,7 +93,6 @@ object VideoDownloadManager { IsDone, IsFailed, IsStopped, - IsPending } enum class DownloadActionType { @@ -113,6 +101,16 @@ 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, @@ -155,35 +153,26 @@ object VideoDownloadManager { @JsonProperty("pkg") val pkg: DownloadResumePackage, ) - 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, - ) + private const val SUCCESS_DOWNLOAD_DONE = 1 + private const val SUCCESS_STREAM = 3 + private const val SUCCESS_STOPPED = 2 - /** 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) + // 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 - /** 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_OPEN_FILE = -3 + private const val ERROR_TOO_SMALL_CONNECTION = -4 - /** 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_WRONG_CONTENT = -5 + private const val ERROR_CONNECTION_ERROR = -6 - /** 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 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 - /** 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" + private const val KEY_RESUME_PACKAGES = "download_resume" const val KEY_DOWNLOAD_INFO = "download_info" private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" @@ -213,33 +202,31 @@ 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() - fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { + private fun Context.getImageBitmapFromUrl(url: String): Bitmap? { try { if (cachedBitmaps.containsKey(url)) { return cachedBitmaps[url] } - val bitmap = Glide.with(this) + val bitmap = GlideApp.with(this) .asBitmap() - .load(GlideUrl(url) { headers ?: emptyMap() }) - .submit(720, 720) + .load(url).into(720, 720) .get() - if (bitmap != null) { cachedBitmaps[url] = bitmap } - return bitmap + return null } catch (e: Exception) { logError(e) return null @@ -260,8 +247,8 @@ object VideoDownloadManager { notificationCallback: (Int, Notification) -> Unit, hlsProgress: Long? = null, hlsTotal: Long? = null, - bytesPerSecond: Long - ): Notification? { + + ): Notification? { try { if (total <= 0) return null// crash, invalid data @@ -281,7 +268,6 @@ object VideoDownloadManager { DownloadType.IsPaused -> imgPaused DownloadType.IsFailed -> imgError DownloadType.IsStopped -> imgStopped - DownloadType.IsPending -> imgDownloading } ) @@ -298,7 +284,6 @@ 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) @@ -306,8 +291,6 @@ 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 "" @@ -334,52 +317,27 @@ 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 = " - $mbFormat".format(progress / 1000000f) + suffix = " - %.1f MB".format(progress / 1000000f) } else { progressPercentage = progress * 100 / total - progressMbString = mbFormat.format(progress / 1000000f) - totalMbString = mbFormat.format(total / 1000000f) + progressMbString = "%.1f MB".format(progress / 1000000f) + totalMbString = "%.1f MB".format(total / 1000000f) suffix = "" } - val mbPerSecondString = - if (state == DownloadType.IsDownloading) { - " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f) - } else "" - val bigText = - 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 - ) - } + 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) } val bodyStyle = NotificationCompat.BigTextStyle() @@ -387,28 +345,14 @@ object VideoDownloadManager { builder.setStyle(bodyStyle) } else { val txt = - 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 - ) - } + 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) } builder.setContentText(txt) @@ -481,16 +425,64 @@ object VideoDownloadManager { } } - private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" - fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + private const val reservedChars = "|\\?*<\":>+[]/\'" + fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String { var tempName = name - for (c in RESERVED_CHARS) { + for (c in reservedChars) { 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: @@ -501,13 +493,76 @@ object VideoDownloadManager { basePath: String? ): List>? { val base = basePathToFile(context, basePath) - val folder = base?.gotoDirectory(relativePath, false) ?: return null - //if (folder.isDirectory() != false) return null + val folder = base?.gotoDir(relativePath, false) - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull 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 +// } } + @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, @@ -515,958 +570,428 @@ object VideoDownloadManager { val bytesTotal: Long, val hlsProgress: Long? = null, val hlsTotal: Long? = null, - val bytesPerSecond: Long ) data class StreamData( - private val fileLength: Long, - val file: SafeFile, - //val fileStream: OutputStream, - ) { - @Throws(IOException::class) - fun open(): OutputStream { - return file.openOutputStreamOrThrow(resume) - } - - @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, - folder: String?, - extension: String, - tryResume: Boolean, - ): StreamData { - return setupStream( - context.getBasePath().first ?: getDefaultDir(context) - ?: throw IOException("Bad config"), - name, - folder, - extension, - tryResume - ) - } + val errorCode: Int, + val resume: Boolean? = null, + val fileLength: Long? = null, + val fileStream: OutputStream? = null, + ) /** * Sets up the appropriate file and creates a data stream from the file. * Used for initializing downloads. * */ - @Throws(IOException::class) fun setupStream( - baseFile: SafeFile, + context: Context, name: String, folder: String?, extension: String, tryResume: Boolean, ): StreamData { val displayName = getDisplayName(name, extension) + val fileStream: OutputStream + val fileLength: Long + var resume = tryResume + val baseFile = context.getBasePath() - val subDir = baseFile.gotoDirectoryOrThrow(folder) - val foundFile = subDir.findFile(displayName) + 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 (file, fileLength) = if (foundFile == null || foundFile.exists() != true) { - subDir.createFileOrThrow(displayName) to 0L - } else { - if (tryResume) { - foundFile to foundFile.lengthOrThrow() - } else { - foundFile.deleteOrThrow() - subDir.createFileOrThrow(displayName) to 0L - } - } + val currentExistingFile = + cr.getExistingDownloadUriOrNullQ( + folder ?: "", + displayName + ) // CURRENT FILE WITH THE SAME PATH - return StreamData(fileLength, file) - } - - /** 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, - - var totalBytes: Long? = null, - - // notification metadata - private var lastUpdatedMs: Long = 0, - private var lastDownloadedBytes: Long = 0, - private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, - - private var internalType: DownloadType = DownloadType.IsPending, - - // 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, - - // 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 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 -> { - type = DownloadType.IsPaused - } - - DownloadActionType.Stop -> { - type = DownloadType.IsStopped - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() - stopListener?.invoke() - stopListener = null - } - - DownloadActionType.Resume -> { - type = DownloadType.IsDownloading - } - } - } - } - - 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 - ) + fileLength = + if (currentExistingFile == null || !resume) 0 else (cr.getFileLength( + currentExistingFile ) - } - } + ?: 0)// IF NOT RESUME THEN 0, OTHERWISE THE CURRENT FILE SIZE - fun setDownloadFileInfoTemplate(template: DownloadedFileInfo) { - downloadFileInfoTemplate = template - updateFileInfo() - } - - init { - if (id != null) { - downloadEvent += downloadEventListener - } - } - - 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 - downloadStatus -= id - } - stopListener = null - } - - var type - get() = internalType - set(value) { - internalType = value - notify() - } - - fun onDelete() { - bytesDownloaded = 0 - hlsWrittenProgress = 0 - hlsProgress = 0 - if (id != null) - downloadDeleteEvent(id) - - //internalType = DownloadType.IsStopped - notify() - } - - 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, - bytes, - hlsTotal = hlsTotal?.toLong(), - hlsProgress = hlsProgress.toLong(), - bytesPerSecond = bytesPerSecond - ) - ) - } else { - createNotificationCallback( - CreateNotificationMetadata( - internalType, - bytesDownloaded, - bytes, - bytesPerSecond = bytesPerSecond - ) - ) - } - - // 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 + 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!!!") } } - } - private fun checkNotification() { - if (lastUpdatedMs + UPDATE_RATE_MS > System.currentTimeMillis()) return - notify() - } + 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 - /** 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)) - } + "vtt" -> null // "text/vtt" + "mp4" -> "video/mp4" + "srt" -> null // "application/x-subrip"//"text/plain" + else -> null } - } catch (e: CancellationException) { - throw e - } catch (t: Throwable) { - logError(t) - } finally { - requestStream.closeQuietly() + 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) } - 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 } + fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else "")) + ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) } 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 + 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 LazyStreamDownloadData( - url = url, - headers = headers, - referer = referer, - chuckStartByte = ranges, - downloadLength = downloadLength, - totalLength = totalLength, - chuckSize = chuckSize, - bufferSize = bufferSize - ) + return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) } - /** 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) - } - } - } - - /** download a file that consist of a single stream of data*/ - suspend fun downloadThing( + fun downloadThing( context: Context, link: IDownloadableMinimum, name: String, - folder: 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 + ): Int { + if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { + return ERROR_UNKNOWN } - var fileStream: OutputStream? = null - //var requestStream: InputStream? = null - val metadata = DownloadMetaData( - totalBytes = 0, - bytesDownloaded = 0, - createNotificationCallback = createNotificationCallback, - id = parentId, + val basePath = context.getBasePath() + + 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 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\"" ) - try { - // 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) + 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) + } - fileStream = stream.open() + if (resume) + connection.setRequestProperty("Range", "bytes=${fileLength}-") + val resumeLength = (if (resume) fileLength else 0) - metadata.setResumeLength(stream.startAt) - metadata.type = DownloadType.IsPending + // ON CONNECTION + connection.connect() - 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", - ) + val contentLength = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // fuck android + connection.contentLengthLong + } else { + connection.getHeaderField("content-length").toLongOrNull() + ?: connection.contentLength.toLong() + } + } 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 ) ) + } - if (items.totalLength != null && items.totalLength < minimumSize) { - fileStream.closeQuietly() - metadata.onDelete() - stream.delete() - return@withContext DOWNLOAD_INVALID_INPUT + // Could use connection.contentType for mime types when creating the file, + // however file is already created and players don't go of file type + + // 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 + }*/ + + // READ DATA FROM CONNECTION + val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream) + val buffer = ByteArray(1024) + var count: Int + var bytesDownloaded = resumeLength + + var isPaused = false + var isStopped = false + var isDone = false + var isFailed = false + + // TO NOT REUSE CODE + fun updateNotification() { + val type = when { + isDone -> DownloadType.IsDone + isStopped -> DownloadType.IsStopped + isFailed -> DownloadType.IsFailed + isPaused -> DownloadType.IsPaused + else -> DownloadType.IsDownloading } - metadata.totalBytes = items.totalLength - metadata.type = DownloadType.IsDownloading - metadata.setDownloadFileInfoTemplate( - DownloadedFileInfo( - totalBytes = metadata.approxTotalBytes, - relativePath = folder, - displayName = displayName, - basePath = basePath + parentId?.let { id -> + try { + downloadStatus[id] = type + downloadStatusEvent.invoke(Pair(id, type)) + downloadProgressEvent.invoke(Triple(id, bytesDownloaded, bytesTotal)) + } catch (e: Exception) { + // IDK MIGHT ERROR + } + } + + createNotificationCallback.invoke( + CreateNotificationMetadata( + type, + bytesDownloaded, + bytesTotal ) ) + /*createNotification( + context, + source, + link.name, + ep, + type, + bytesDownloaded, + bytesTotal + )*/ + } - 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 + val downloadEventListener = { event: Pair -> + if (event.first == parentId) { + when (event.second) { + DownloadActionType.Pause -> { + isPaused = true; updateNotification() } - fileMutex.withLock { - metadata.type = DownloadType.IsStopped + DownloadActionType.Stop -> { + isStopped = true; updateNotification() + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + saveQueue() } + DownloadActionType.Resume -> { + isPaused = false; updateNotification() + } + } + } + } + + if (parentId != null) + downloadEvent += downloadEventListener + + // UPDATE DOWNLOAD NOTIFICATION + val notificationCoroutine = main { + while (true) { + if (!isPaused) { + updateNotification() + } + for (i in 1..10) { + delay(100) + } + } + } + + // 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) } - - 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 + } catch (e: Exception) { logError(e) - throw e - } catch (t: Throwable) { - // some sort of network error, will error + isFailed = true + updateNotification() + } - // 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() + // REMOVE AND EXIT ALL + fileStream.close() + connectionInputStream.close() + notificationCoroutine.cancel() + + try { + if (parentId != null) + downloadEvent -= downloadEventListener + } catch (e: Exception) { + logError(e) + } + + try { + parentId?.let { + downloadStatus.remove(it) + } + } catch (e: Exception) { + // IDK MIGHT ERROR + } + + // 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, + bytesDownloaded, + bytesTotal + ) + ) + } + isDone = true + updateNotification() + SUCCESS_DOWNLOAD_DONE + } } } - 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 + /** + * 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}") + 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 + // 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) } } - // 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 - } + val allDirectories = directoryName?.split("/") + return if (allDirectories?.size == 1 || allDirectories == null) { + val found = this.findFile(directoryName) + when { + directoryName.isNullOrBlank() -> this + found?.isDirectory == true -> found - // 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) - } - } + !createMissingDirectories -> null + // Below creates directories + found?.isFile == true -> { + found.delete() + this.createDirectory(directoryName) } + this.isDirectory -> this.createDirectory(directoryName) + else -> this.parentFile?.createDirectory(directoryName) } + } 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 } - - // 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() + } catch (e: Exception) { + logError(e) + return null } } @@ -1481,10 +1006,21 @@ 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 getDefaultDir(context: Context): SafeFile? { + fun getDownloadDir(): UniFile? { // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads + 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 ) } @@ -1492,11 +1028,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?): SafeFile? { + private fun basePathToFile(context: Context, path: String?): UniFile? { return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFile(context, File(path)) + path.isNullOrBlank() -> getDownloadDir() + path.startsWith("content://") -> UniFile.fromUri(context, path.toUri()) + else -> UniFile.fromFile(File(path)) } } @@ -1505,12 +1041,300 @@ 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) } @@ -1543,7 +1367,7 @@ object VideoDownloadManager { ) } - private suspend fun downloadSingleEpisode( + private fun downloadSingleEpisode( context: Context, source: String?, folder: String?, @@ -1551,12 +1375,7 @@ object VideoDownloadManager { link: ExtractorLink, notificationCallback: (Int, Notification) -> Unit, tryResume: Boolean = false, - ): DownloadStatus { - // no support for these file formats - if (link.type == ExtractorLinkType.MAGNET || link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.DASH) { - return DOWNLOAD_INVALID_INPUT - } - + ): Int { val name = getFileName(context, ep) // Make sure this is cancelled when download is done or cancelled. @@ -1566,267 +1385,223 @@ object VideoDownloadManager { } } - val callback: (CreateNotificationMetadata) -> Unit = { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback, - meta.hlsProgress, - meta.hlsTotal, - meta.bytesPerSecond - ) - } - } - - 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( + 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, - link, - name, - folder ?: "", - ep.id, - startIndex, - callback, parallelConnections = maxConcurrentConnections + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback, + meta.hlsProgress, + meta.hlsTotal ) } + }.also { extractorJob.cancel() } + } - ExtractorLinkType.VIDEO -> { - return downloadThing( + return normalSafeApiCall { + downloadThing(context, link, name, folder, "mp4", tryResume, ep.id) { meta -> + main { + createNotification( context, - link, - name, - folder ?: "", - "mp4", - tryResume, - ep.id, - callback, - parallelConnections = maxConcurrentConnections, - /** We require at least 10 MB video files */ - minimumSize = (1 shl 20) * 10 + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback ) } - - else -> throw IllegalArgumentException("unsuported download type") } - } catch (t: Throwable) { - return DOWNLOAD_FAILED - } finally { - extractorJob.cancel() - } + }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN } - suspend fun downloadCheck( + fun downloadCheck( context: Context, notificationCallback: (Int, Notification) -> Unit, - ) { - if (!(currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0)) return + ): 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 + } - 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) - currentDownloads.add(id) - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index + 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) - ) - - 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 + 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) } } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the work manager - downloadCheckUsingWorker(context) } - - // return id + return null } - /* 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) + fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { + val res = getDownloadFileInfo(context, id) + if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) + return res } - private fun getDownloadFileInfo( - context: Context, - id: Int, - ): DownloadedFileInfoResult? { + private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { try { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null - val file = info.toFile(context) + val base = basePathToFile(context, info.basePath) - // 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 + 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) } - - return DownloadedFileInfoResult( - file.lengthOrThrow(), - info.totalBytes, - file.uriOrThrow() - ) } catch (e: Exception) { logError(e) return null } } - 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) + /** + * 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 } } - private fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { + 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 - val file = info.toFile(context) - - downloadEvent.invoke(id to DownloadActionType.Stop) + downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(id to DownloadType.IsStopped) + 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 isFileDeleted = file?.delete() == true || file?.exists() == false - if (isFileDeleted) deleteMatchingSubtitles(context, info) - - return isFileDeleted + 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 + } + } } fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - suspend fun downloadFromResume( + fun downloadFromResume( context: Context, pkg: DownloadResumePackage, notificationCallback: (Int, Notification) -> Unit, setKey: Boolean = true ) { - if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { + 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 +//// ) +// } +// } downloadQueue.addLast(pkg) downloadCheck(context, notificationCallback) if (setKey) saveQueue() - //ret } else { - downloadEvent( - pkg.item.ep.id to DownloadActionType.Resume + downloadEvent.invoke( + Pair(pkg.item.ep.id, DownloadActionType.Resume) ) - //null } } @@ -1837,7 +1612,7 @@ object VideoDownloadManager { .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } .toTypedArray() setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) - } catch (t: Throwable) { + } catch (t : Throwable) { logError(t) } } @@ -1852,7 +1627,7 @@ object VideoDownloadManager { return false }*/ - suspend fun downloadEpisode( + fun downloadEpisode( context: Context?, source: String?, folder: String?, @@ -1861,12 +1636,13 @@ object VideoDownloadManager { notificationCallback: (Int, Notification) -> Unit, ) { if (context == null) return - if (links.isEmpty()) return - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) + if (links.isNotEmpty()) { + downloadFromResume( + context, + DownloadResumePackage(DownloadItem(source, folder, ep, links), null), + notificationCallback + ) + } } /** Worker stuff */ @@ -1926,4 +1702,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 deleted file mode 100644 index e7c36a87..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index 1f33bca4..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastSession.kt +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 61c00d6e..00000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/Packets.kt +++ /dev/null @@ -1,62 +0,0 @@ -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 2aea0b8d..d4725d53 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 deleted file mode 100644 index 76e7b39b..00000000 --- a/app/src/main/res/anim/rotate_around_center_point.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - \ 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 deleted file mode 100644 index 9975946d..00000000 --- a/app/src/main/res/color/button_selector_color.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ 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 deleted file mode 100644 index feb1eeb0..00000000 --- a/app/src/main/res/color/player_on_button_tv_attr.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ 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 deleted file mode 100644 index e0237df0..00000000 --- a/app/src/main/res/color/white_attr_20.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 3a72cda0..00000000 --- a/app/src/main/res/drawable/baseline_help_outline_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/baseline_network_ping_24.xml b/app/src/main/res/drawable/baseline_network_ping_24.xml deleted file mode 100644 index 1caae667..00000000 --- a/app/src/main/res/drawable/baseline_network_ping_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/baseline_notifications_none_24.xml b/app/src/main/res/drawable/baseline_notifications_none_24.xml deleted file mode 100644 index cf589c6d..00000000 --- a/app/src/main/res/drawable/baseline_notifications_none_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/baseline_remove_24.xml b/app/src/main/res/drawable/baseline_remove_24.xml index f4455598..791a2f81 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/white"> + android:tint="?attr/colorControlNormal"> diff --git a/app/src/main/res/drawable/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml deleted file mode 100644 index 100cb1fc..00000000 --- a/app/src/main/res/drawable/baseline_stop_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/baseline_text_snippet_24.xml b/app/src/main/res/drawable/baseline_text_snippet_24.xml deleted file mode 100644 index c1f3654b..00000000 --- a/app/src/main/res/drawable/baseline_text_snippet_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/circle_shape_dotted.xml b/app/src/main/res/drawable/circle_shape_dotted.xml deleted file mode 100644 index 6ce2808c..00000000 --- a/app/src/main/res/drawable/circle_shape_dotted.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - \ 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 deleted file mode 100644 index a2e7f022..00000000 --- a/app/src/main/res/drawable/circular_progress_bar_clockwise.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ 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 deleted file mode 100644 index 477e8db1..00000000 --- a/app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - \ 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 deleted file mode 100644 index eed44628..00000000 --- a/app/src/main/res/drawable/circular_progress_bar_small_to_large.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ 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 deleted file mode 100644 index f41eea84..00000000 --- a/app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 3810b4bf..00000000 --- a/app/src/main/res/drawable/cloud_2_solid.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - \ 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 deleted file mode 100644 index a41ac14e..00000000 --- a/app/src/main/res/drawable/download_icon_done.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - \ 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 deleted file mode 100644 index ef56f19a..00000000 --- a/app/src/main/res/drawable/download_icon_error.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - \ 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 deleted file mode 100644 index bde9a160..00000000 --- a/app/src/main/res/drawable/download_icon_load.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ 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 deleted file mode 100644 index 08455521..00000000 --- a/app/src/main/res/drawable/download_icon_pause.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - \ 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 deleted file mode 100644 index a77cbf25..00000000 --- a/app/src/main/res/drawable/episodes_shadow.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 764cb966..00000000 Binary files a/app/src/main/res/drawable/example_qr.png and /dev/null differ diff --git a/app/src/main/res/drawable/hourglass_24.xml b/app/src/main/res/drawable/hourglass_24.xml deleted file mode 100644 index 7bd1ebbd..00000000 --- a/app/src/main/res/drawable/hourglass_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - 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 dbda1cc0..ebe459b2 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,7 +3,6 @@ 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 516df382..6c3197a6 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,11 +1,5 @@ - - + + 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 48ac45e7..2ec8c110 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,7 +3,6 @@ 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 deleted file mode 100644 index dba3e567..00000000 --- a/app/src/main/res/drawable/ic_baseline_edit_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ 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 deleted file mode 100644 index cd20ad15..00000000 --- a/app/src/main/res/drawable/ic_baseline_equalizer_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - 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 deleted file mode 100644 index 941d936f..00000000 --- a/app/src/main/res/drawable/ic_baseline_film_roll_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - \ 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 b67188db..916c761c 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,11 +1,5 @@ - - + + 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 89b47937..1749952e 100644 --- a/app/src/main/res/drawable/ic_baseline_language_24.xml +++ b/app/src/main/res/drawable/ic_baseline_language_24.xml @@ -1,10 +1,5 @@ - - + + 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 b6908e96..249fe2a2 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,10 +1,5 @@ - - + + 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 5d6045e7..2003bfe7 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,10 +1,5 @@ - - + + 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 deleted file mode 100644 index 2651015c..00000000 --- a/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_replay_24.xml b/app/src/main/res/drawable/ic_baseline_replay_24.xml deleted file mode 100644 index e247aa92..00000000 --- a/app/src/main/res/drawable/ic_baseline_replay_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_restart_24.xml b/app/src/main/res/drawable/ic_baseline_restart_24.xml deleted file mode 100644 index aed3a562..00000000 --- a/app/src/main/res/drawable/ic_baseline_restart_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow.xml deleted file mode 100644 index 0326fbd4..00000000 --- a/app/src/main/res/drawable/ic_baseline_resume_arrow.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml deleted file mode 100644 index fc533a0e..00000000 --- a/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - 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 deleted file mode 100644 index a8c43bbd..00000000 --- a/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - 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 deleted file mode 100644 index 452c4dd9..00000000 --- a/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_battery.xml b/app/src/main/res/drawable/ic_battery.xml deleted file mode 100644 index 24d0a77f..00000000 --- a/app/src/main/res/drawable/ic_battery.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml deleted file mode 100644 index 4b8964f8..00000000 --- a/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - \ 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 deleted file mode 100644 index 5c96e5a5..00000000 --- a/app/src/main/res/drawable/ic_fingerprint.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_network_stream.xml b/app/src/main/res/drawable/ic_network_stream.xml deleted file mode 100644 index 8e21fd25..00000000 --- a/app/src/main/res/drawable/ic_network_stream.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - 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 27c2d574..cc564471 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,13 +1,6 @@ - - - + + + diff --git a/app/src/main/res/drawable/library_icon.xml b/app/src/main/res/drawable/library_icon.xml deleted file mode 100644 index f62dceac..00000000 --- a/app/src/main/res/drawable/library_icon.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/netflix_skip_back.xml b/app/src/main/res/drawable/netflix_skip_back.xml index 5ad9c1a1..bb63e948 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: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:fillColor="#00000000" + android:strokeColor="#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" + android:fillColor="#fff"/> \ 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 deleted file mode 100644 index a4e18af3..00000000 --- a/app/src/main/res/drawable/outline_bookmark_add_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/outline_card.xml b/app/src/main/res/drawable/outline_card.xml index 5716de45..02116bb8 100644 --- a/app/src/main/res/drawable/outline_card.xml +++ b/app/src/main/res/drawable/outline_card.xml @@ -1,20 +1,21 @@ + 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 deleted file mode 100644 index 16eba83c..00000000 --- a/app/src/main/res/drawable/outline_drawable_forced.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 7736f088..00000000 --- a/app/src/main/res/drawable/outline_drawable_forced_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ 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 aa3a8d0d..0b641074 100644 --- a/app/src/main/res/drawable/outline_drawable_less.xml +++ b/app/src/main/res/drawable/outline_drawable_less.xml @@ -1,5 +1,4 @@ - - + \ 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 deleted file mode 100644 index ed83887d..00000000 --- a/app/src/main/res/drawable/player_button_tv_attr.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 0dd8c256..00000000 --- a/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ 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 deleted file mode 100644 index 657f6247..00000000 --- a/app/src/main/res/drawable/preview_seekbar_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/profile_bg_blue.jpg b/app/src/main/res/drawable/profile_bg_blue.jpg deleted file mode 100644 index e573439b..00000000 Binary files a/app/src/main/res/drawable/profile_bg_blue.jpg and /dev/null 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 deleted file mode 100644 index d59e4888..00000000 Binary files a/app/src/main/res/drawable/profile_bg_dark_blue.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/profile_bg_orange.jpg b/app/src/main/res/drawable/profile_bg_orange.jpg deleted file mode 100644 index a97e7179..00000000 Binary files a/app/src/main/res/drawable/profile_bg_orange.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/profile_bg_pink.jpg b/app/src/main/res/drawable/profile_bg_pink.jpg deleted file mode 100644 index 9d4940f0..00000000 Binary files a/app/src/main/res/drawable/profile_bg_pink.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/profile_bg_purple.jpg b/app/src/main/res/drawable/profile_bg_purple.jpg deleted file mode 100644 index 15723dba..00000000 Binary files a/app/src/main/res/drawable/profile_bg_purple.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/profile_bg_red.jpg b/app/src/main/res/drawable/profile_bg_red.jpg deleted file mode 100644 index 6a27ff31..00000000 Binary files a/app/src/main/res/drawable/profile_bg_red.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/profile_bg_teal.jpg b/app/src/main/res/drawable/profile_bg_teal.jpg deleted file mode 100644 index 93236650..00000000 Binary files a/app/src/main/res/drawable/profile_bg_teal.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml deleted file mode 100644 index b85ace8e..00000000 --- a/app/src/main/res/drawable/rounded_outline.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/screen_rotation.xml b/app/src/main/res/drawable/screen_rotation.xml deleted file mode 100644 index da0ac0fd..00000000 --- a/app/src/main/res/drawable/screen_rotation.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/simkl_logo.xml b/app/src/main/res/drawable/simkl_logo.xml deleted file mode 100644 index eb29fb5b..00000000 --- a/app/src/main/res/drawable/simkl_logo.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/subdl_logo_big.xml b/app/src/main/res/drawable/subdl_logo_big.xml deleted file mode 100644 index a6cbb311..00000000 --- a/app/src/main/res/drawable/subdl_logo_big.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/video_frame.xml b/app/src/main/res/drawable/video_frame.xml deleted file mode 100644 index 19fcf26d..00000000 --- a/app/src/main/res/drawable/video_frame.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - \ 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 deleted file mode 100644 index 9d39425a..00000000 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index f133d6c3..00000000 --- a/app/src/main/res/layout/account_list_item.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - \ 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 deleted file mode 100644 index dea64484..00000000 --- a/app/src/main/res/layout/account_list_item_add.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - \ 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 deleted file mode 100644 index 0adade19..00000000 --- a/app/src/main/res/layout/account_list_item_edit.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - \ 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 e7afb382..389a3406 100644 --- a/app/src/main/res/layout/account_managment.xml +++ b/app/src/main/res/layout/account_managment.xml @@ -62,16 +62,14 @@ + android:id="@+id/account_switch_account" + android:text="@string/switch_account" + style="@style/SettingsItem" /> + android:id="@+id/account_logout" + android:text="@string/logout" + style="@style/SettingsItem"> diff --git a/app/src/main/res/layout/account_select_linear.xml b/app/src/main/res/layout/account_select_linear.xml deleted file mode 100644 index b78c0d44..00000000 --- a/app/src/main/res/layout/account_select_linear.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - \ 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 c4f7fa39..cbfb9f18 100644 --- a/app/src/main/res/layout/account_single.xml +++ b/app/src/main/res/layout/account_single.xml @@ -1,11 +1,10 @@ + 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: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 5153f0e3..659ad840 100644 --- a/app/src/main/res/layout/account_switch.xml +++ b/app/src/main/res/layout/account_switch.xml @@ -7,20 +7,18 @@ 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:id="@+id/account_add" + android:text="@string/add_account" + style="@style/SettingsItem"> diff --git a/app/src/main/res/layout/activity_account_select.xml b/app/src/main/res/layout/activity_account_select.xml deleted file mode 100644 index bd6007dc..00000000 --- a/app/src/main/res/layout/activity_account_select.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - \ 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 a70a40cd..dc29dec9 100644 --- a/app/src/main/res/layout/activity_main_tv.xml +++ b/app/src/main/res/layout/activity_main_tv.xml @@ -41,22 +41,6 @@ - - - - - - - - \ 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 653f607f..9ef6ad6a 100644 --- a/app/src/main/res/layout/add_remove_sites.xml +++ b/app/src/main/res/layout/add_remove_sites.xml @@ -1,21 +1,19 @@ + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/add_site" + android:text="@string/add_site_pref" + style="@style/SettingsItem"> + android:id="@+id/remove_site" + android:text="@string/remove_site_pref" + 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 cb4224d1..6f6b4d5b 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/repo_url_input" + android:nextFocusDown="@id/site_url_input" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> @@ -85,8 +85,9 @@ android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - android:nextFocusUp="@id/repo_name_input" - android:nextFocusDown="@id/apply_btt" + + android:nextFocusUp="@id/site_name_input" + android:nextFocusDown="@id/site_lang_input" 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 3372fe7b..ce41cb65 100644 --- a/app/src/main/res/layout/bottom_resultview_preview.xml +++ b/app/src/main/res/layout/bottom_resultview_preview.xml @@ -41,50 +41,17 @@ android:layout_marginStart="10dp" android:orientation="vertical"> - + + android:textStyle="bold" + tools:text="The Perfect Run"> - - - - - + - - - - - - + android:background="?android:attr/selectableItemBackground" + android:gravity="start|center_vertical" + android:padding="12dp" + android:text="@string/home_more_info" + android:textColor="?attr/textColor" + app:drawableRightCompat="@drawable/ic_baseline_arrow_forward_24" + app:drawableTint="?attr/white" /> + xmlns:tools="http://schemas.android.com/tools" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/text1" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:layout_marginTop="20dp" + android:layout_marginBottom="10dp" + android:textStyle="bold" + android:textSize="20sp" + android:textColor="?attr/textColor" + android:layout_width="match_parent" + android:layout_rowWeight="1" + tools:text="Test" + android:layout_height="wrap_content" /> + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + + android:id="@+id/listview1" + android:layout_marginBottom="60dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + tools:listitem="@layout/sort_bottom_single_choice_no_checkmark" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" /> diff --git a/app/src/main/res/layout/bottom_text_dialog.xml b/app/src/main/res/layout/bottom_text_dialog.xml deleted file mode 100644 index 01b4834d..00000000 --- a/app/src/main/res/layout/bottom_text_dialog.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 99a9750b..8403940c 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -1,98 +1,105 @@ - - + app:cardCornerRadius="@dimen/rounded_image_radius" + app:cardBackgroundColor="@color/transparent" + + android:foreground="@drawable/outline_drawable" + android:layout_margin="5dp"> + + android:layout_gravity="center_horizontal" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + app:cardCornerRadius="35dp" + android:layout_width="70dp" + android:layout_height="70dp" + android:foreground="@drawable/outline_drawable"> + android:id="@+id/actor_image" + tools:src="@drawable/example_poster" + + android:scaleType="centerCrop" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" /> + android:id="@+id/voice_actor_image_holder" + android:layout_gravity="end|bottom" + app:cardCornerRadius="20dp" + android:layout_width="40dp" + android:layout_height="40dp" + android:foreground="@drawable/outline_drawable"> + android:id="@+id/voice_actor_image" + tools:src="@drawable/example_poster" + + android:scaleType="centerCrop" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" /> + android:paddingTop="3dp" + android:paddingBottom="3dp" + android:orientation="vertical" + android:layout_gravity="center" + android:gravity="center_horizontal" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + android:gravity="center_horizontal" + android:id="@+id/actor_name" + tools:text="Ackerman, Mikasa" + android:textStyle="bold" + android:textColor="?attr/textColor" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + android:gravity="center_horizontal" + android:id="@+id/voice_actor_name" + tools:text="voiceactor" + android:textColor="?attr/grayTextColor" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + android:gravity="center_horizontal" + android:id="@+id/actor_extra" + tools:text="Main" + android:textColor="?attr/grayTextColor" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> diff --git a/app/src/main/res/layout/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 4d3b50df..624c2201 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 e0eac5e0..7803e261 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="40dp"> + android:layout_marginEnd="30dp"> @@ -107,8 +106,7 @@ android:layout_margin="10dp" android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/change_providers_img_des" - android:focusable="true" - android:nextFocusLeft="@id/year_btt" + android:nextFocusLeft="@id/main_search" 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 deleted file mode 100644 index e8023243..00000000 --- a/app/src/main/res/layout/download_button.xml +++ /dev/null @@ -1,9 +0,0 @@ - - \ 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 deleted file mode 100644 index 0ceca181..00000000 --- a/app/src/main/res/layout/download_button_layout.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 6e40a597..00000000 --- a/app/src/main/res/layout/download_button_view.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - \ 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 e53e63d3..f2633dd6 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -1,98 +1,118 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + + android:nextFocusRight="@id/download_child_episode_download" + android:nextFocusLeft="@id/nav_rail_view" + + android:id="@+id/download_child_episode_holder" + android:layout_width="match_parent" + android:layout_height="50dp" + app:cardCornerRadius="@dimen/rounded_image_radius" + app:cardBackgroundColor="@color/transparent" + app:cardElevation="0dp" + android:foreground="@drawable/outline_drawable" + android:layout_marginBottom="5dp"> + android:id="@+id/download_child_episode_progress" + + android:layout_marginBottom="-1.5dp" + android:progressTint="?attr/colorPrimary" + android:progressBackgroundTint="?attr/colorPrimary" + style="@android:style/Widget.Material.ProgressBar.Horizontal" + android:layout_width="match_parent" + tools:progress="50" + android:layout_gravity="bottom" + android:layout_height="5dp" /> + android:foreground="?android:attr/selectableItemBackgroundBorderless" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/download_child_episode_play" + android:visibility="gone" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:layout_gravity="center_vertical" + android:src="@drawable/ic_baseline_play_arrow_24" + android:contentDescription="@string/episode_play_img_des" /> + android:layout_gravity="center_vertical" + android:orientation="vertical" + android:layout_height="wrap_content" + android:layout_marginEnd="50dp" + android:layout_width="match_parent"> + android:id="@+id/download_child_episode_text" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:layout_gravity="center_vertical" + android:gravity="center_vertical" + tools:text="Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1" + + android:scrollHorizontally="true" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:singleLine="true" + + android:textColor="?attr/textColor" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + android:id="@+id/download_child_episode_text_extra" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:layout_gravity="center_vertical" + android:gravity="center_vertical" + tools:text="128MB / 237MB" + android:textColor="?attr/grayTextColor" + android:layout_width="wrap_content" + android:layout_height="match_parent" /> - + - + + + + \ 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 385fb2e0..da4b3617 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 @@ - - - - + app:cardCornerRadius="@dimen/rounded_image_radius" + app:cardBackgroundColor="?attr/boxItemBackground" + android:id="@+id/episode_holder" + android:foreground="@drawable/outline_drawable" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:layout_marginTop="10dp"> + + + android:layout_width="70dp" + android:layout_height="104dp"> + android:id="@+id/download_header_poster" + tools:src="@drawable/example_poster" + android:scaleType="centerCrop" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" /> + android:layout_marginStart="15dp" + android:orientation="vertical" + android:layout_gravity="center" + android:layout_width="match_parent" + android:layout_marginEnd="70dp" + android:layout_height="wrap_content"> + android:id="@+id/download_header_title" + tools:text="Perfect Run" + android:textStyle="bold" + android:textColor="?attr/textColor" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + android:id="@+id/download_header_info" + tools:text="1 episode | 285MB" + android:textColor="?attr/grayTextColor" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + android:layout_marginStart="-50dp" + android:layout_height="match_parent" + android:padding="50dp" + android:layout_width="50dp" + android:id="@+id/download_header_goto_child" + android:layout_gravity="center_vertical|end" + android:src="@drawable/ic_baseline_keyboard_arrow_right_24" + android:contentDescription="@string/download" /> - + - + + + + \ 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 64ed1d70..a3cc8ce8 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -1,95 +1,39 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:background="?attr/primaryGrayBackground" + android:id="@+id/download_child_root" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".ui.download.DownloadFragment"> - - - - - -