diff --git a/.github/locales.py b/.github/locales.py index a74d72588..6127d9d80 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,14 +1,13 @@ 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" START_MARKER = "/* begin language list */" END_MARKER = "/* end language list */" -XML_NAME = "app/src/main/res/values-" +XML_NAME = "app/src/main/res/values-b+" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" INDENT = " "*4 @@ -21,29 +20,29 @@ rest, after_src = rest.split(END_MARKER) # Load already added langs languages = {} -for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest): - flag, name, iso = lang.groups() - languages[iso] = (flag, name) +for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): + name, iso = lang.groups() + languages[iso] = name # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): - iso = folder[len(XML_NAME):] + iso = folder[len(XML_NAME):].replace("+", "-") if iso not in languages.keys(): - entry = iso_map.get(iso.lower(),{'nativeName':iso}) - languages[iso] = ("", entry['nativeName'].split(',')[0]) + entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found + languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple -# Create triples -triples = [] -for iso in sorted(languages.keys()): - flag, name = languages[iso] - triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),') +# Create pairs +pairs = [] +for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name + name = languages[iso] + pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + - "\n".join(triples) + + "\n".join(pairs) + "\n" + END_MARKER + after_src @@ -62,8 +61,5 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"): with open(file, 'wb') as fp: fp.write(b'\n') tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) - # Remove trailing new line to be consistent with weblate - fp.seek(-1, os.SEEK_END) - fp.truncate() except ET.ParseError as ex: print(f"[{file}] {ex}") diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index f62f1ba05..056022d22 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -9,7 +9,10 @@ on: - '**/wcokey.txt' workflow_dispatch: -concurrency: +permissions: + contents: read + +concurrency: group: "Archive-build" cancel-in-progress: true @@ -24,6 +27,7 @@ jobs: 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 @@ -31,14 +35,18 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream-archive" - - uses: actions/checkout@v4 + + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Fetch keystore id: fetch_keystore run: | @@ -49,25 +57,34 @@ jobs: KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Run Gradle - run: | - ./gradlew assemblePrerelease + run: ./gradlew assemblePrereleaseRelease 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 }} - - uses: actions/checkout@v4 + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} + MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + MAL_KEY: ${{ secrets.MAL_KEY }} + ANILIST_KEY: ${{ secrets.ANILIST_KEY }} + + - uses: actions/checkout@v6 with: repository: "recloudstream/cloudstream-archive" token: ${{ steps.generate_archive_token.outputs.token }} path: "archive" - name: Move build - run: | - cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" - + run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" + - name: Push archive run: | cd $GITHUB_WORKSPACE/archive @@ -75,4 +92,4 @@ jobs: git config --local user.name "GitHub Actions" git add . git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit - git push --force \ No newline at end of file + git push --force diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index 666e2ba10..d67b8a519 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -1,19 +1,18 @@ name: Dokka -# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency -concurrency: - group: "dokka" - cancel-in-progress: true - on: push: - branches: - # choose your default branch - - master - - main + branches: [ master ] paths-ignore: - '*.md' +permissions: + contents: read + +concurrency: + group: "dokka" + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest @@ -25,32 +24,35 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" + - name: Checkout - uses: actions/checkout@master + uses: actions/checkout@v6 with: path: "src" - name: Checkout dokka - uses: actions/checkout@master + uses: actions/checkout@v6 with: repository: "recloudstream/dokka" path: "dokka" token: ${{ steps.generate_token.outputs.token }} - + - name: Clean old builds run: | cd $GITHUB_WORKSPACE/dokka/ rm -rf "./app" rm -rf "./library" - - name: Setup JDK 17 - uses: actions/setup-java@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: + distribution: temurin java-version: 17 - distribution: 'adopt' - - name: Setup Android SDK - uses: android-actions/setup-android@v3 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Generate Dokka run: | @@ -59,8 +61,7 @@ jobs: ./gradlew docs:dokkaGeneratePublicationHtml - name: Copy Dokka - run: | - cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ + run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ - name: Push builds run: | diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml deleted file mode 100644 index 88ab3656c..000000000 --- a/.github/workflows/issue_action.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Issue automatic actions - -on: - issues: - types: [opened] - -jobs: - issue-moderator: - runs-on: ubuntu-latest - steps: - - name: Generate access token - id: generate_token - uses: tibdex/github-app-token@v2 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - - name: Similarity analysis - id: similarity - uses: actions-cool/issues-similarity-analysis@v1 - with: - token: ${{ steps.generate_token.outputs.token }} - filter-threshold: 0.60 - title-excludes: '' - comment-title: | - ### Your issue looks similar to these issues: - Please close if duplicate. - comment-body: '${index}. ${similarity} #${number}' - - name: Label if possible duplicate - if: steps.similarity.outputs.similar-issues-found =='true' - uses: actions/github-script@v7 - with: - github-token: ${{ steps.generate_token.outputs.token }} - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ["possible duplicate"] - }) - - uses: actions/checkout@v4 - - name: Automatically close issues that dont follow the issue template - uses: lucasbento/auto-close-issues@v1.0.2 - with: - github-token: ${{ steps.generate_token.outputs.token }} - issue-close-message: | - @${issue.user.login}: hello! :wave: - This issue is being automatically closed because it does not follow the issue template." - closed-issues-label: "invalid" - - name: Check if issue mentions a provider - id: provider_check - env: - GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}" - run: | - wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py" - pip3 install httpx - RES="$(python3 ./check_issue.py)" - echo "name=${RES}" >> $GITHUB_OUTPUT - - name: Comment if issue mentions a provider - if: steps.provider_check.outputs.name != 'none' - uses: actions-cool/issues-helper@v3 - with: - actions: 'create-comment' - token: ${{ steps.generate_token.outputs.token }} - body: | - Hello ${{ github.event.issue.user.login }}. - Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). - - 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 - with: - github-token: ${{ steps.generate_token.outputs.token }} - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ["possible provider issue"] - }) - - name: Add eyes reaction to all issues - uses: actions-cool/emoji-helper@v1.0.0 - with: - type: 'issue' - token: ${{ steps.generate_token.outputs.token }} - emoji: 'eyes' - - diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index dd608b321..f089afa8f 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,10 +8,13 @@ on: - '*.json' - '**/wcokey.txt' -concurrency: +concurrency: group: "pre-release" cancel-in-progress: true +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest @@ -23,14 +26,18 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - uses: actions/checkout@v4 + + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Fetch keystore id: fetch_keystore run: | @@ -41,19 +48,27 @@ jobs: KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Run Gradle - run: | - ./gradlew assemblePrerelease build androidSourcesJar - ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease + run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar 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 }} + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + MAL_KEY: ${{ secrets.MAL_KEY }} + ANILIST_KEY: ${{ secrets.ANILIST_KEY }} + - name: Create pre-release - uses: "marvinpinto/action-automatic-releases@latest" + uses: marvinpinto/action-automatic-releases@latest with: repo_token: "${{ secrets.GITHUB_TOKEN }}" automatic_release_tag: "pre-release" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 7f6dd4123..8f5c62866 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,22 +2,35 @@ name: Artifact Build on: [pull_request] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: false + - name: Run Gradle - run: ./gradlew assemblePrereleaseDebug + run: ./gradlew assemblePrereleaseDebug lint check + - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 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 ce140e559..0a538d5d4 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,17 +1,19 @@ name: Fix locale issues on: - workflow_dispatch: push: + branches: [ master ] paths: - '**.xml' - branches: - - master + workflow_dispatch: -concurrency: +concurrency: group: "locale" cancel-in-progress: true +permissions: + contents: read + jobs: create: runs-on: ubuntu-latest @@ -23,15 +25,17 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - uses: actions/checkout@v4 + + - uses: actions/checkout@v6 with: token: ${{ steps.generate_token.outputs.token }} + - name: Install dependencies - run: | - pip3 install lxml + run: pip3 install lxml requests + - name: Edit files - run: | - python3 .github/locales.py + run: python3 .github/locales.py + - name: Commit to the repo run: | git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" diff --git a/AI-POLICY.md b/AI-POLICY.md new file mode 100644 index 000000000..5409393fb --- /dev/null +++ b/AI-POLICY.md @@ -0,0 +1,11 @@ +# AI Policy + +AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions. + +1. Always state any AI usage in pull requests and issues. + +2. Always test code before making a pull request. We do not want to test your AI generated code. + +3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI. + +4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5203a28cb..66a55ae88 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,53 +1,96 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier +import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { - id("com.android.application") - id("kotlin-android") - id("org.jetbrains.dokka") + alias(libs.plugins.android.application) + alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.serialization) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) -val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" -val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() -fun getGitCommitHash(): String { - return try { - val headFile = file("${project.rootDir}/.git/HEAD") +abstract class GenerateGitHashTask : DefaultTask() { - // Read the commit hash from .git/HEAD - if (headFile.exists()) { - val headContent = headFile.readText().trim() - if (headContent.startsWith("ref:")) { - val refPath = headContent.substring(5) // e.g., refs/heads/main - val commitFile = file("${project.rootDir}/.git/$refPath") - if (commitFile.exists()) commitFile.readText().trim() else "" - } else headContent // If it's a detached HEAD (commit hash directly) - } else { - "" // If .git/HEAD doesn't exist - }.take(7) // Return the short commit hash - } catch (_: Throwable) { - "" // Just return an empty string if any exception occurs + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headFile: RegularFileProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headsDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val head = headFile.get().asFile + + val hash = try { + if (head.exists()) { + // Read the commit hash from .git/HEAD + val headContent = head.readText().trim() + if (headContent.startsWith("ref:")) { + val refPath = headContent.substring(5) // e.g., refs/heads/main + val commitFile = File(head.parentFile, refPath) + if (commitFile.exists()) commitFile.readText().trim() else "" + } else headContent // If it's a detached HEAD (commit hash directly) + } else "" // If .git/HEAD doesn't exist + } catch (_: Throwable) { + "" // Just set to an empty string if any exception occurs + }.take(7) // Get the short commit hash + + val outFile = outputDir.file("git-hash.txt").get().asFile + outFile.parentFile.mkdirs() + outFile.writeText(hash) } } +val generateGitHash = tasks.register("generateGitHash") { + val gitDir = layout.projectDirectory.dir("../.git") + + headFile.set(gitDir.file("HEAD")) + headsDir.set(gitDir.dir("refs/heads")) + + outputDir.set(layout.buildDirectory.dir("generated/git")) +} + android { @Suppress("UnstableApiUsage") testOptions { unitTests.isReturnDefaultValues = true } - viewBinding { - enable = true + // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + + androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory( + generateGitHash, + GenerateGitHashTask::outputDir + ) + } } signingConfigs { - if (prereleaseStoreFile != null) { + // We just use SIGNING_KEY_ALIAS here since it won't change + // so won't kill the configuration cache. + if (System.getenv("SIGNING_KEY_ALIAS") != null) { create("prerelease") { - storeFile = file(prereleaseStoreFile) + val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" + val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() + + storeFile = prereleaseStoreFile?.let { file(it) } storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") @@ -61,12 +104,10 @@ android { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 66 - versionName = "4.5.4" + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.versionName.get() - resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") - resValue("string", "commit_hash", getGitCommitHash()) - resValue("bool", "is_prerelease", "false") + manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() // Reads local.properties val localProperties = gradleLocalProperties(rootDir, project.providers) @@ -86,6 +127,16 @@ android { "SIMKL_CLIENT_SECRET", "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) + buildConfigField( + "String", + "MAL_KEY", + "\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\"" + ) + buildConfigField( + "String", + "ANILIST_KEY", + "\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\"" + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -113,12 +164,9 @@ android { productFlavors { create("stable") { dimension = "state" - resValue("bool", "is_prerelease", "false") } create("prerelease") { dimension = "state" - resValue("bool", "is_prerelease", "true") - buildConfigField("boolean", "BETA", "true") applicationIdSuffix = ".prerelease" if (signingConfigs.names.contains("prerelease")) { signingConfig = signingConfigs.getByName("prerelease") @@ -136,13 +184,29 @@ android { targetCompatibility = JavaVersion.toVersion(javaTarget.target) } + java { + // Use Java 17 toolchain even if a higher JDK runs the build. + // We still use Java 8 for now which higher JDKs have deprecated. + toolchain { + languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) + } + } + lint { - abortOnError = false checkReleaseBuilds = false } buildFeatures { buildConfig = true + viewBinding = true + } + + packaging { + jniLibs { + // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23). + // Note: This may increase app startup time slightly. + useLegacyPackaging = true + } } namespace = "com.lagradost.cloudstream3" @@ -153,43 +217,46 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) - implementation(libs.junit.ktx) - androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.instancio.core) + androidTestImplementation(libs.junit.ktx) + androidTestImplementation(libs.kotlin.test) // Android Core & Lifecycle implementation(libs.core.ktx) + implementation(libs.activity.ktx) + implementation(libs.annotation) implementation(libs.appcompat) - implementation(libs.bundles.navigationKtx) - implementation(libs.lifecycle.livedata.ktx) - implementation(libs.lifecycle.viewmodel.ktx) + implementation(libs.fragment.ktx) + implementation(libs.bundles.lifecycle) + implementation(libs.bundles.navigation) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) // JSON Parser // Design & UI implementation(libs.preference.ktx) implementation(libs.material) implementation(libs.constraintlayout) - implementation(libs.swiperefreshlayout) // Coil Image Loading - implementation(libs.coil) - implementation(libs.coil.network.okhttp) + implementation(libs.bundles.coil) // Media 3 (ExoPlayer) implementation(libs.bundles.media3) implementation(libs.video) + // FFmpeg Decoding + implementation(libs.bundles.nextlib) + + // Anime-db for filler + implementation(libs.anime.db) + // PlayBack implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.newpipeextractor) // For Trailers implementation(libs.juniversalchardet) // Subtitle Decoding - // FFmpeg Decoding - implementation(libs.bundles.nextlibMedia3) - - // Crash Reports (AcraApplication.kt) - implementation(libs.acra.core) - implementation(libs.acra.toast) - // UI Stuff implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) implementation(libs.palette.ktx) // Palette for Images -> Colors @@ -200,50 +267,34 @@ dependencies { implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV // Extensions & Other Libs + implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript - implementation(libs.quickjs) - implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance implementation(libs.safefile) // To Prevent the URI File Fu*kery coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor - implementation(libs.conscrypt.android) { - version { - strictly("2.5.2") - } - because("2.5.3 crashes everything for everyone.") - } // To Fix SSL Fu*kery on Android 9 - implementation(libs.jackson.module.kotlin) { - version { - strictly("2.13.1") - } - because("Don't Bump Jackson above 2.13.1, Crashes on Android TV's and FireSticks that have Min API Level 25 or Less.") - } // JSON Parser + implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 + implementation(libs.jackson.module.kotlin) // JSON Parser + implementation(libs.zipline) + + // Deprecated; will be removed once extensions have time to migrate from using it + implementation("me.xdrop:fuzzywuzzy:1.4.0") // Torrent Support implementation(libs.torrentserver) // Downloading & Networking - implementation(libs.work.runtime) implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib - 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) - } - } - - this.extra.set("isDebug", isDebug) - }) + implementation(project(":library")) } tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources + from(android.sourceSets.getByName("main").java.directories) // Full Sources } tasks.register("copyJar") { + dependsOn("build", ":library:jvmJar") from( "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", "../library/build/libs" @@ -270,15 +321,23 @@ tasks.register("makeJar") { tasks.withType { compilerOptions { jvmTarget.set(javaTarget) - freeCompilerArgs.add("-Xjvm-default=all-compatibility") + jvmDefault.set(JvmDefaultMode.ENABLE) + freeCompilerArgs.add("-Xannotation-default-target=param-property") + optIn.addAll( + "com.lagradost.cloudstream3.InternalAPI", + "com.lagradost.cloudstream3.Prerelease", + "kotlin.uuid.ExperimentalUuidApi", + ) } } dokka { moduleName = "App" dokkaSourceSets { - main { + configureEach { + suppress = name != "prereleaseDebug" analysisPlatform = KotlinPlatform.JVM + displayName = "JVM" documentedVisibilities( VisibilityModifier.Public, VisibilityModifier.Protected diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 000000000..b2f5e8f2b --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 0adfc1fae..4c5cdea5b 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -136,14 +136,14 @@ class ExampleInstrumentedTest { @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()) + val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } + Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty()) for (api in getAllProviders()) { Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE") Assert.assertTrue("Api does not contain a name", api.name != "NONE") Assert.assertTrue( "Api ${api.name} does not contain a valid language code", - isoNames.contains(api.lang) + langTagsIETF.contains(api.lang) ) Assert.assertTrue( "Api ${api.name} does not contain any supported types", diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt new file mode 100644 index 000000000..80c7b49b0 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt @@ -0,0 +1,134 @@ +package com.lagradost.cloudstream3 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import dalvik.system.DexFile +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import kotlinx.serialization.serializerOrNull +import org.instancio.Instancio +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmName +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class SerializationClassTester { + // Same as app, or using app reference + val jacksonMapper = mapper + val kotlinxMapper = json + + @Test + fun isIdenticalSerialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + + val jacksonJson = jacksonMapper.writeValueAsString(instance) + val kotlinxJson = serializeWithKotlinx(kClass, instance) + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical serialization for: ${kClass.jvmName}") + } + } + + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + @Test + fun isIdenticalDeserialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + // Convert to JSON to get example JSON object + // We prefer jackson here because the app may have many jackson JSON strings in local storage + val originalJson = jacksonMapper.writeValueAsString(instance) + + // Create an object from the JSON using kotlinx + val serializer = + kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass) + assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!") + val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson) + + // Create an object from the JSON using jackson + val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java) + + + // Deep inspect both object using the mapper toJson function. + // This deep equality check can be performed using other methods, but this just works. + val jacksonJson = mapperDecoded.toJson() + val kotlinxJson = kotlinxDecoded.toJson() + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical deserialization for: ${kClass.jvmName}") + } + } + + // DEX files are the best solution to read all our classes dynamically. + // classgraph could be used instead, but it only gives results on the JVM, not Android. + @Suppress("DEPRECATION") + private fun findSerializableClasses(packageName: String): List> { + val context = InstrumentationRegistry + .getInstrumentation() + .targetContext + + val dexFile = DexFile(context.packageCodePath) + return dexFile.entries() + .toList() + .filter { it.startsWith(packageName) } + .mapNotNull { + runCatching { Class.forName(it).kotlin }.getOrNull() + }.filter { kClass -> + // Not possible to use .hasAnnotation() on newer Android versions. + kClass.java.annotations.any { + it is Serializable + } + } + } + + @OptIn(InternalSerializationApi::class) + @Suppress("UNCHECKED_CAST") + private fun serializeWithKotlinx( + kClass: KClass<*>, + value: Any + ): String { + val serializer = kClass.serializer() as KSerializer + return kotlinxMapper.encodeToString(serializer, value) + } +} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt new file mode 100644 index 000000000..15ad532f8 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt @@ -0,0 +1,157 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = NonEmptyData.Serializer::class) +data class NonEmptyData( + val title: String = "", + val tags: List = emptyList(), + val meta: Map = emptyMap(), + val name: String = "hello", +) { + object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = WriteOnlyData.Serializer::class) +data class WriteOnlyData( + val fieldA: String = "", + val fieldB: String = "", +) { + object Serializer : WriteOnlySerializer( + WriteOnlyData.generatedSerializer(), + setOf("fieldB"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = MultiWriteOnly.Serializer::class) +data class MultiWriteOnly( + val fieldA: String = "", + val fieldB: String = "", + val fieldC: String = "", +) { + object Serializer : WriteOnlySerializer( + MultiWriteOnly.generatedSerializer(), + setOf("fieldB", "fieldC"), + ) +} + +@Serializable +data class UriData( + @Serializable(with = UriSerializer::class) + val uri: Uri = Uri.EMPTY, +) + +class SerializerTest { + + @Test + fun nonEmptySerializerOmitsEmptyStrings() { + val data = NonEmptyData(title = "", name = "hello") + val result = data.toJson() + assertFalse(result.contains("title")) + assertTrue(result.contains("name")) + } + + @Test + fun nonEmptySerializerOmitsEmptyLists() { + val data = NonEmptyData(tags = emptyList(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("tags")) + } + + @Test + fun nonEmptySerializerOmitsEmptyMaps() { + val data = NonEmptyData(meta = emptyMap(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("meta")) + } + + @Test + fun nonEmptySerializerKeepsNonEmptyFields() { + val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) + val result = data.toJson() + assertTrue(result.contains("title")) + assertTrue(result.contains("tags")) + assertTrue(result.contains("meta")) + } + + @Test + fun nonEmptySerializerDoesNotAffectDeserialization() { + val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertEquals(listOf("a"), result.tags) + assertEquals(mapOf("k" to "v"), result.meta) + assertEquals("world", result.name) + } + + @Test + fun writeOnlySerializerOmitsFieldOnSerialize() { + val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + } + + @Test + fun writeOnlySerializerDeserializesNormally() { + val input = """{"fieldA":"hello","fieldB":"secret"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("secret", result.fieldB) + } + + @Test + fun writeOnlySerializerDeserializesMissingAsDefault() { + val input = """{"fieldA":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("", result.fieldB) + } + + @Test + fun writeOnlySerializerHandlesMultipleKeys() { + val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + assertFalse(result.contains("fieldC")) + } + + @Test + fun uriSerializerSerializesUriToString() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val result = data.toJson() + assertTrue(result.contains("https://example.com/path?query=1")) + } + + @Test + fun uriSerializerDeserializesStringToUri() { + val input = """{"uri":"https://example.com/path?query=1"}""" + val result = parseJson(input) + assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) + } + + @Test + fun uriSerializerRoundtripsCorrectly() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data.uri, decoded.uri) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d960d910c..ee4c978f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,8 +2,6 @@ - - @@ -18,12 +16,53 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tools:targetApi="${target_sdk_version}"> + android:supportsPictureInPicture="true" /> + + + + + + + + + + + + @@ -144,7 +200,14 @@ + + + + + + + @@ -168,7 +231,7 @@ - + @@ -181,21 +244,6 @@ - - - - - - - - - - - - + + -#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 9f493fbbc..bbe7d97de 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -1,233 +1,78 @@ package com.lagradost.cloudstream3 -import android.app.Activity -import android.app.Application -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import coil3.PlatformContext -import coil3.SingletonImageLoader -import com.lagradost.api.setContext -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.mvvm.safeAsync -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.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.DataStore.removeKeys -import com.lagradost.cloudstream3.utils.DataStore.setKey -import com.lagradost.cloudstream3.utils.ImageLoader -import kotlinx.coroutines.runBlocking -import org.acra.ACRA -import org.acra.ReportField -import org.acra.config.CoreConfiguration -import org.acra.data.CrashReportData -import org.acra.data.StringFormat -import org.acra.ktx.initAcra -import org.acra.sender.ReportSender -import org.acra.sender.ReportSenderFactory -import java.io.File -import java.io.FileNotFoundException -import java.io.PrintStream -import java.lang.ref.WeakReference -import java.util.Locale -import kotlin.concurrent.thread -import kotlin.system.exitProcess +/** + * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. + * Use CloudStreamApp instead. + */ +@Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), + level = DeprecationLevel.WARNING +) +class AcraApplication { + companion object { -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/$id/formResponse" - val data = mapOf( - "entry.$entry" to errorContent.toJSON() - ) + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), + level = DeprecationLevel.WARNING + ) + val context get() = CloudStreamApp.context - thread { // to not run it on main thread - runBlocking { - safeAsync { - app.post(url, data = data) - //println("Report response: $post") - } - } - } + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), + level = DeprecationLevel.WARNING + ) + fun removeKeys(folder: String): Int? = + CloudStreamApp.removeKeys(folder) - runOnMainThread { // to run it on main looper - safe { - Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show() - } - }*/ - } + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), + level = DeprecationLevel.WARNING + ) + fun setKey(path: String, value: T) = + CloudStreamApp.setKey(path, value) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), + level = DeprecationLevel.WARNING + ) + fun setKey(folder: String, path: String, value: T) = + CloudStreamApp.setKey(folder, path, value) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(path: String, defVal: T?): T? = + CloudStreamApp.getKey(path, defVal) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(path: String): T? = + CloudStreamApp.getKey(path) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(folder: String, path: String): T? = + CloudStreamApp.getKey(folder, path) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(folder: String, path: String, defVal: T?): T? = + CloudStreamApp.getKey(folder, path, defVal) + } } - -class CustomSenderFactory : ReportSenderFactory { - override fun create(context: Context, config: CoreConfiguration): ReportSender { - return CustomReportSender() - } - - override fun enabled(config: CoreConfiguration): Boolean { - return true - } -} - -class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : - Thread.UncaughtExceptionHandler { - override fun uncaughtException(thread: Thread, error: Throwable) { - 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})") - error.printStackTrace(ps) - } - } catch (ignored: FileNotFoundException) { - } - try { - onError.invoke() - } catch (ignored: Exception) { - } - exitProcess(1) - } - -} - -class AcraApplication : Application(), SingletonImageLoader.Factory { - - override fun onCreate() { - super.onCreate() - // if we want to initialise coil at earliest - // (maybe when loading an image or gif using in splash screen activity) - //ImageLoader.buildImageLoader(applicationContext) - - 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?) { - super.attachBaseContext(base) - context = base - - initAcra { - //core configuration: - buildConfigClass = BuildConfig::class.java - reportFormat = StringFormat.JSON - - reportContent = listOf( - ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, - ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, - ReportField.STACK_TRACE, - ) - - // removed this due to bug when starting the app, moved it to when it actually crashes - //each plugin you chose above can be configured in a block like this: - /*toast { - text = getString(R.string.acra_report_toast) - //opening this block automatically enables the plugin. - }*/ - } - } - - override fun newImageLoader(context: PlatformContext): coil3.ImageLoader { - // Coil Module will be initialized & setSafe globally when first loadImage() is invoked - return ImageLoader.buildImageLoader(applicationContext) - } - - companion object { - var exceptionHandler: ExceptionHandler? = null - - /** Use to get activity from Context */ - tailrec fun Context.getActivity(): Activity? { - return when (this) { - is Activity -> this - is ContextWrapper -> baseContext.getActivity() - else -> null - } - } - - private var _context: WeakReference? = null - var context - 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) - } - - fun setKey(path: String, value: T) { - context?.setKey(path, value) - } - - fun setKey(folder: String, path: String, value: T) { - context?.setKey(folder, path, value) - } - - inline fun getKey(path: String, defVal: T?): T? { - return context?.getKey(path, defVal) - } - - inline fun getKey(path: String): T? { - return context?.getKey(path) - } - - inline fun getKey(folder: String, path: String): T? { - return context?.getKey(folder, path) - } - - inline fun getKey(folder: String, path: String, defVal: T?): T? { - return context?.getKey(folder, path, defVal) - } - - fun getKeys(folder: String): List? { - return context?.getKeys(folder) - } - - fun removeKey(folder: String, path: String) { - context?.removeKey(folder, path) - } - - fun removeKey(path: String) { - context?.removeKey(path) - } - - /** - * If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails. - * */ - fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) { - context?.openBrowser(url, fallbackWebview, fragment) - } - - /** Will fallback to webview if in TV layout */ - fun openBrowser(url: String, activity: FragmentActivity?) { - openBrowser( - url, - isLayout(TV or EMULATOR), - activity?.supportFragmentManager?.fragments?.lastOrNull() - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt new file mode 100644 index 000000000..a9cd9c01e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,181 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import com.lagradost.api.setContext +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeAsync +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.utils.AppDebug +import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.removeKeys +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader +import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.FileNotFoundException +import java.io.PrintStream +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.concurrent.thread +import kotlin.system.exitProcess + +class ExceptionHandler( + val errorFile: File, + val onError: (() -> Unit) +) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(thread: Thread, error: Throwable) { + try { + val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + thread.threadId() + } else { + @Suppress("DEPRECATION") + thread.id + } + + PrintStream(errorFile).use { ps -> + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} ($threadId)") + error.printStackTrace(ps) + } + } catch (_: FileNotFoundException) { + } + try { + onError() + } catch (_: Exception) { + } + exitProcess(1) + } +} + +class CloudStreamApp : Application(), SingletonImageLoader.Factory { + + override fun onCreate() { + super.onCreate() + // If we want to initialize Coil as early as possible, maybe when + // loading an image or GIF in a splash screen activity. + // buildImageLoader(applicationContext) + + ExceptionHandler(filesDir.resolve("last_error")) { + val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) + startActivity(Intent.makeRestartActivityTask(intent!!.component)) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } + + AppDebug.isDebug = BuildConfig.DEBUG + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + context = base + } + + override fun newImageLoader(context: PlatformContext): ImageLoader { + // Coil module will be initialized globally when first loadImage() is invoked. + return buildImageLoader(applicationContext) + } + + companion object { + var exceptionHandler: ExceptionHandler? = null + + /** Use to get Activity from Context. */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + } + + private var _context: WeakReference? = null + var context + 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) + } + + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } + + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } + + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } + + inline fun getKey(path: String): T? { + return context?.getKey(path) + } + + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } + + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } + + fun getKeys(folder: String): List? { + return context?.getKeys(folder) + } + + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + + fun removeKey(path: String) { + context?.removeKey(path) + } + + /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */ + fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) { + context?.openBrowser(url, fallbackWebView, fragment) + } + + /** Will fall back to WebView if in TV or emulator layout. */ + fun openBrowser(url: String, activity: FragmentActivity?) { + openBrowser( + url, + isLayout(TV or EMULATOR), + activity?.supportFragmentManager?.fragments?.lastOrNull() + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 34698fee0..4ce09bd44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -1,13 +1,16 @@ package com.lagradost.cloudstream3 -import android.Manifest +import android.annotation.SuppressLint 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.Manifest import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.DisplayMetrics import android.util.Log import android.view.Gravity @@ -24,35 +27,41 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.children +import androidx.core.view.isNotEmpty 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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.ui.player.PlayerEventType +import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter +import com.lagradost.cloudstream3.ui.home.ParentItemAdapter +import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible import com.lagradost.cloudstream3.ui.player.Torrent -import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.result.ActorAdaptor +import com.lagradost.cloudstream3.ui.result.EpisodeAdapter +import com.lagradost.cloudstream3.ui.result.ImageAdapter +import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.updateTv +import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission -import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode +import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod import com.lagradost.cloudstream3.utils.UIHelper.toPx -import org.schabi.newpipe.extractor.NewPipe +import com.lagradost.cloudstream3.utils.UiText import java.lang.ref.WeakReference import java.util.Locale import kotlin.math.max import kotlin.math.min +import org.schabi.newpipe.extractor.NewPipe enum class FocusDirection { Start, @@ -101,15 +110,15 @@ object CommonActivity { return displayMetrics.heightPixels } - var canEnterPipMode: Boolean = false - var canShowPipMode: Boolean = false + var isPipDesired: Boolean = false var isInPIPMode: Boolean = false val onColorSelectedEvent = Event>() val onDialogDismissedEvent = Event() - var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null + var appliedTheme: Int = 0 + var appliedColor: Int = 0 private var currentToast: Toast? = null @@ -182,23 +191,35 @@ object CommonActivity { currentToast = toast toast.show() + val handler = Handler(Looper.getMainLooper()) + val ref = WeakReference(toast) + + /* Clean up activity leak */ + handler.postDelayed({ + if (ref.get() == currentToast) { + currentToast = null + } + }, 10_000) + } catch (e: Exception) { logError(e) } } /** - * Not all languages can be fetched from locale with a code. - * This map allows sidestepping the default Locale(languageCode) - * when setting the app language. - **/ - val appLanguageExceptions = hashMapOf( - "zh-rTW" to Locale.TRADITIONAL_CHINESE - ) - - fun setLocale(context: Context?, languageCode: String?) { - if (context == null || languageCode == null) return - val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode) + * Set locale + * @param languageTag shall a IETF BCP 47 conformant tag. + * Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. + * + * See locales on: + * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json + * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml + * https://iso639-3.sil.org/code_tables/639/data/all + */ + fun setLocale(context: Context?, languageTag: String?) { + if (context == null || languageTag == null) return + val locale = Locale.forLanguageTag(languageTag) val resources: Resources = context.resources val config = resources.configuration Locale.setDefault(locale) @@ -206,6 +227,7 @@ object CommonActivity { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.createConfigurationContext(config) + @Suppress("DEPRECATION") resources.updateConfiguration( config, @@ -222,16 +244,8 @@ object CommonActivity { fun init(act: Activity) { setActivityInstance(act) ioSafe { Torrent.deleteAllFiles() } - val componentActivity = activity as? ComponentActivity ?: return - //https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission - //https://developer.android.com/guide/topics/ui/picture-in-picture - canShowPipMode = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT - 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 - componentActivity.updateLocale() componentActivity.updateTv() AccountManager.initMainAPI() @@ -247,7 +261,7 @@ object CommonActivity { ?: return@registerForActivityResult action.onResultSafe(act, result.data) removeKey("last_click_action") - removeKey("last_opened_id") + removeKey("last_opened") } } @@ -269,13 +283,15 @@ object CommonActivity { } } + /** Enters pip mode if it is both possible and desired to do so*/ private fun Activity.enterPIPMode() { - if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return + if (!isPipDesired || !this.isPIPPossible()) return + try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) - } catch (e: Exception) { + } catch (_: Exception) { // Use fallback just in case @Suppress("DEPRECATION") enterPictureInPictureMode() @@ -291,10 +307,10 @@ object CommonActivity { } } - fun onUserLeaveHint(act: Activity?) { - if (canEnterPipMode && canShowPipMode) { - act?.enterPIPMode() - } + fun onUserLeaveHint(act: Activity) { + // On Android 12 and later we use setAutoEnterEnabled() instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return + act.enterPIPMode() } fun updateTheme(act: Activity) { @@ -334,6 +350,10 @@ object CommonActivity { "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.MonetMode else R.style.AppTheme + "Dracula" -> R.style.DraculaMode + "Lavender" -> R.style.LavenderMode + "SilentBlue" -> R.style.SilentBlueMode + else -> R.style.AppTheme } @@ -369,6 +389,8 @@ object CommonActivity { act.theme.applyStyle(currentTheme, true) act.theme.applyStyle(currentOverlayTheme, true) + appliedTheme = currentTheme + appliedColor = currentOverlayTheme act.updateTv() if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true) act.theme.applyStyle( @@ -401,8 +423,7 @@ object CommonActivity { private fun View.hasContent(): Boolean { return isShown && when (this) { - //is RecyclerView -> this.childCount > 0 - is ViewGroup -> this.childCount > 0 + is ViewGroup -> this.isNotEmpty() else -> true } } @@ -432,7 +453,7 @@ object CommonActivity { // 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 + parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() } ?: false if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null @@ -511,87 +532,7 @@ object CommonActivity { fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { - - // 149 keycode_numpad 5 - val playerEvent = 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, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { - PlayerEventType.NextEpisode - } - - KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { - 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 - } - // OpenSubtitles shortcut - 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 -> return null - } - val listener = playerEventListener - if (listener != null) { - listener.invoke(playerEvent) - return true - } return null - - //when (keyCode) { - // KeyEvent.KEYCODE_DPAD_CENTER -> { - // println("DPAD PRESSED") - // } - //} } /** overrides focus and custom key events */ @@ -628,6 +569,7 @@ object CommonActivity { else -> null } + // println("NEXT FOCUS : $nextView") if (nextView != null) { nextView.requestFocus() @@ -635,10 +577,15 @@ object CommonActivity { return true } - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && + // TODO: Figure out why removing the check for SearchAutoComplete seems + // to break focus on TV as it shouldn't need to be used. + // Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote) + // send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button. + @SuppressLint("RestrictedApi") + if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) && (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) ) { - UIHelper.showInputMethod(act.currentFocus?.findFocus()) + showInputMethod(act.currentFocus?.findFocus()) } //println("Keycode: $keyCode") @@ -647,7 +594,6 @@ object CommonActivity { // "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 @@ -657,4 +603,4 @@ object CommonActivity { } return null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt deleted file mode 100644 index 045a7963a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.lagradost.cloudstream3 - -import android.view.LayoutInflater -import androidx.annotation.LayoutRes -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.ui.HeaderViewDecoration - -fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) { - val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null) - view.addItemDecoration(HeaderViewDecoration(headerView)) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index ed3db1493..90583011d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -9,7 +9,6 @@ import android.content.SharedPreferences import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Rect -import android.net.Uri import android.os.Bundle import android.util.AttributeSet import android.util.Log @@ -24,14 +23,14 @@ import android.widget.CheckBox import android.widget.ImageView import android.widget.LinearLayout 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.cardview.widget.CardView -import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.edit +import androidx.core.net.toUri import androidx.core.view.children import androidx.core.view.get import androidx.core.view.isGone @@ -65,9 +64,9 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.initAll -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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent @@ -98,6 +97,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STR 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.APP_STRING_SHARE import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.APIRepository @@ -119,6 +119,7 @@ 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.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsGeneral @@ -156,17 +157,20 @@ 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.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar +import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding 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.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API @@ -184,7 +188,9 @@ import java.nio.charset.Charset import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.system.exitProcess - +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { @@ -194,6 +200,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa const val ANIMATED_OUTLINE: Boolean = false var lastError: String? = null + /** Update lastError variable based on error file, to check if app crashed. + * Can be called multiple times without changing the lastError variable changing. + **/ + fun setLastError(context: Context) { + if (lastError != null) return + + val errorFile = context.filesDir.resolve("last_error") + if (errorFile.exists() && errorFile.isFile) { + lastError = errorFile.readText(Charset.defaultCharset()) + errorFile.delete() + } else { + lastError = null + } + } + private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" @@ -255,7 +276,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * @return true if the str has launched an app task (be it successful or not) * @param isWebview does not handle providers and opening download page if true. Can still add repos and login. * */ - @Suppress("DEPRECATION_ERROR") fun handleAppIntentUrl( activity: FragmentActivity?, str: String?, @@ -332,7 +352,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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 uri = str.toUri() val name = uri.getQueryParameter("name") val url = URLDecoder.decode(uri.authority, "UTF-8") @@ -342,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa LinkGenerator( listOf(BasicLink(url, name)), extract = true, - ) + id = url.hashCode() + ), 0 ) ) } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { @@ -358,6 +379,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa START_ACTION_RESUME_LATEST ) } + } else if (str.startsWith(APP_STRING_SHARE)) { + try { + val data = str.substringAfter("$APP_STRING_SHARE:") + val parts = data.split("?", limit = 2) + loadResult( + String(base64DecodeArray(parts[1]), Charsets.UTF_8), + String(base64DecodeArray(parts[0]), Charsets.UTF_8), + "" + ) + return true + } catch (e: Exception) { + showToast("Invalid Uri", Toast.LENGTH_SHORT) + return false + } } else if (!isWebview) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { this.navigate(R.id.navigation_downloads) @@ -373,22 +408,39 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa return true } - synchronized(apis) { - for (api in apis) { - if (str.startsWith(api.mainUrl)) { - loadResult(str, api.name, "") - return true - } - } + val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() + if (matchedApi != null) { + loadResult(str, matchedApi.name, "") + return true } } } } return false } + + + 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) { + } + } } + var lastPopup: SearchResponse? = null + var lastPopupJob: Job? = null fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result val syncName = syncViewModel.syncName(result.apiName) @@ -404,7 +456,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa syncViewModel.clear() } - if (load) { + lastPopupJob?.cancel() + lastPopupJob = if (load) { viewModel.load( this, result.url, result.apiName, false, if (getApiDubstatusSettings() .contains(DubStatus.Dubbed) @@ -451,6 +504,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_downloads, R.id.navigation_settings, R.id.navigation_download_child, + R.id.navigation_download_queue, R.id.navigation_subtitles, R.id.navigation_chrome_subtitles, R.id.navigation_settings_player, @@ -465,7 +519,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ).contains(destination.id) - val dontPush = listOf( + /*val dontPush = listOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_results_phone, @@ -496,25 +550,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } layoutParams = params - } - - val landscape = when (resources.configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> { - true - } - - Configuration.ORIENTATION_PORTRAIT -> { - isLayout(TV or EMULATOR) - } - - else -> { - false - } - } + }*/ binding?.apply { - navRailView.isVisible = isNavVisible && landscape - navView.isVisible = isNavVisible && !landscape + navRailView.isVisible = isNavVisible && isLandscape() + navView.isVisible = isNavVisible && !isLandscape() + navHostFragment.apply { + val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) + layoutParams = + (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { + marginStart = + if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 + } + } /** * We need to make sure if we return to a sub-fragment, @@ -522,7 +570,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * highlight the wrong one in UI. */ when (destination.id) { - in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> { + in listOf( + R.id.navigation_downloads, + R.id.navigation_download_child, + R.id.navigation_download_queue + ) -> { navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true } @@ -644,7 +696,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } .setPositiveButton(R.string.yes) { _, _ -> if (dontShowAgainCheck.isChecked) { - settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit() + settingsManager.edit(commit = true) { + putInt(getString(R.string.confirm_exit_key), 1) + } } // finish() causes a bug on some TVs where player // may keep playing after closing the app. @@ -669,10 +723,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded + detachBackPressedCallback("MainActivityDefault") super.onDestroy() } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { handleAppIntent(intent) super.onNewIntent(intent) } @@ -681,6 +736,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (intent == null) return val str = intent.dataString loadCache() + handleAppIntentUrl(this, str, false, intent.extras) } @@ -750,12 +806,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - private val pluginsLock = Mutex() private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { - synchronized(allProviders) { + allProviders.withLock { // Load cloned sites after plugins have been loaded since clones depend on plugins. try { getKey>(USER_PROVIDER_API)?.let { list -> @@ -801,6 +856,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) + lastPopupJob?.cancel() + lastPopupJob = null bottomPreviewPopup = null bottomPreviewBinding = null } @@ -1120,35 +1177,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - 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) { - } - } - - @Suppress("DEPRECATION_ERROR") override fun onCreate(savedInstanceState: Bundle?) { - app.initClient(this) + app.initClient(this, ignoreSSL = false) + @OptIn(UnsafeSSL::class) + insecureApp.initClient(this, ignoreSSL = true) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val errorFile = filesDir.resolve("last_error") - if (errorFile.exists() && errorFile.isFile) { - lastError = errorFile.readText(Charset.defaultCharset()) - errorFile.delete() - } else { - lastError = null - } + setLastError(this) val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = @@ -1157,6 +1193,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa MainAPI.settingsForProvider = settingsForProvider loadThemes(this) + enableEdgeToEdgeCompat() + setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) try { @@ -1177,6 +1215,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" if (appVer != lastAppAutoBackup) { setKey("VERSION_NAME", BuildConfig.VERSION_NAME) + if (lastAppAutoBackup.isEmpty()) return@safe + safe { backup(this) } @@ -1208,7 +1248,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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_play_btt, R.id.home_preview_info_btt, R.id.home_preview_hidden_next_focus, R.id.home_preview_hidden_prev_focus, @@ -1240,6 +1280,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa null } + binding?.apply { + fixSystemBarsPadding( + navView, + heightResId = R.dimen.nav_view_height, + padTop = false, + overlayCutout = false + ) + + fixSystemBarsPadding( + navRailView, + widthResId = R.dimen.nav_rail_view_width, + padRight = false, + padTop = false + ) + } + // overscan val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx binding?.homeRoot?.setPadding(padding, padding, padding, padding) @@ -1330,6 +1386,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false ) } + +// Add your channel creation here + } } else { val builder: AlertDialog.Builder = AlertDialog.Builder(this) @@ -1594,9 +1653,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) @@ -1619,10 +1676,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (navDestination.matchDestination(R.id.navigation_home)) { attachBackPressedCallback("MainActivity") { showConfirmExitDialog(settingsManager) - @Suppress("DEPRECATION") - window?.navigationBarColor = - colorFromAttribute(R.attr.primaryGrayBackground) - updateLocale() } } else detachBackPressedCallback("MainActivity") } @@ -1654,14 +1707,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } binding?.navRailView?.apply { - itemRippleColor = rippleColor - itemActiveIndicatorColor = rippleColor + if (isLayout(PHONE)) { + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor + } else { + val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f)) + val rippleColorTransparent = + ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f)) + itemSpacing = 12.toPx // expandedItemSpacing does not have an attr + itemRippleColor = rippleColorTransparent + itemActiveIndicatorColor = rippleColor + } setupWithNavController(navController) - if (isLayout(TV or EMULATOR)) { + /*if (isLayout(TV or EMULATOR)) { background?.alpha = 200 } else { background?.alpha = 255 - } + }*/ setOnItemSelectedListener { item -> onNavDestinationSelected( @@ -1709,6 +1771,58 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + val rail = binding?.navRailView + if (rail != null) { + binding?.navRailView?.labelVisibilityMode = + NavigationRailView.LABEL_VISIBILITY_UNLABELED + //val focus = mutableSetOf() + + var prevId: Int? = null + var prevView: View? = null + + // The genius engineers at google did not actually + // write a nextFocus for the navrail + rail.findViewById(R.id.navigation_settings)?.nextFocusDownId = + R.id.nav_footer_profile_card + for (id in arrayOf( + R.id.navigation_home, + R.id.navigation_search, + R.id.navigation_library, + R.id.navigation_downloads, + R.id.navigation_settings + )) { + val view = rail.findViewById(id) ?: continue + prevId?.let { view.nextFocusUpId = it } + prevView?.nextFocusDownId = id + + prevView = view + prevId = id + // Uncomment for focus expand + /*if (!isLayout(TV)) { + view.onFocusChangeListener = null + } else { + view.onFocusChangeListener = + View.OnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focus += id + binding?.navRailView?.labelVisibilityMode = + NavigationRailView.LABEL_VISIBILITY_LABELED + binding?.navRailView?.expand() + } else { + focus -= id + v.post { + if (focus.isEmpty()) { + binding?.navRailView?.labelVisibilityMode = + NavigationRailView.LABEL_VISIBILITY_UNLABELED + binding?.navRailView?.collapse() + } + } + } + } + }*/ + } + } + // Navigation button long click functionality to scroll to top for (view in listOf(binding?.navView, binding?.navRailView)) { view?.findViewById(R.id.navigation_home)?.setOnLongClickListener { @@ -1821,7 +1935,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa fun buildMediaQueueItem(video: String): MediaQueueItem { // val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO) //movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream") - val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString()) + val mediaInfo = MediaInfo.Builder(video.toUri().toString()) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setContentType(MimeTypes.IMAGE_JPEG) // .setMetadata(movieMetadata).build() @@ -1847,7 +1961,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - synchronized(allProviders) { + allProviders.withLock { for (api in allProviders) { providersAndroidManifestString += "(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> DataStoreHelper.currentHomePage = homepage removeKey(USER_SELECTED_HOMEPAGE_API) @@ -1914,23 +2039,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // } // } - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - @Suppress("DEPRECATION") - window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground) - updateLocale() + attachBackPressedCallback("MainActivityDefault") { + setNavigationBarColorCompat(R.attr.primaryGrayBackground) + updateLocale() + runDefault() + } - // 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 - } - } - ) + // Start the download queue + DownloadQueueManager.init(this) } /** Biometric stuff **/ @@ -1953,4 +2069,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt index cc64a6d39..ac912cbeb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt @@ -6,8 +6,8 @@ import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import androidx.core.net.toUri -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult @@ -21,7 +21,8 @@ import java.io.File fun updateDurationAndPosition(position: Long, duration: Long) { if (position <= 0 || duration <= 0) return - DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration) + val episode = getKey("last_opened") ?: return + DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null) ResultFragment.updateUI() } @@ -98,7 +99,7 @@ abstract class OpenInAppAction( intent.component = ComponentName(packageName, intentClass) } putExtra(context, intent, video, result, index) - setKey("last_opened_id", video.id) + setKey("last_opened", video) launchResult(intent) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt index 8407fa7a4..a864b5fb7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -16,12 +16,16 @@ import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage +import com.lagradost.cloudstream3.actions.temp.MpvExPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage +import com.lagradost.cloudstream3.actions.temp.MpvRxPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage +import com.lagradost.cloudstream3.actions.temp.OnlyPlayer import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction +import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage import com.lagradost.cloudstream3.actions.temp.VlcPackage @@ -30,8 +34,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.UiText import kotlinx.coroutines.Dispatchers @@ -41,14 +45,16 @@ import java.util.concurrent.FutureTask import kotlin.reflect.jvm.jvmName object VideoClickActionHolder { - val allVideoClickActions = threadSafeListOf( + val allVideoClickActions = atomicListOf( // Default PlayInBrowserAction(), CopyClipboardAction(), ViewM3U8Action(), + PlayMirrorAction(), // main support external apps VlcPackage(), MpvPackage(), + MpvExPackage(), NextPlayerPackage(), JustPlayerPackage(), FcastAction(), @@ -60,6 +66,8 @@ object VideoClickActionHolder { MpvYTDLPackage(), MpvKtPackage(), MpvKtPreviewPackage(), + OnlyPlayer(), + MpvRxPackage(), // Always Ask option AlwaysAskAction(), // added by plugins diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt index d7f69db2c..d414b6117 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt @@ -5,8 +5,8 @@ import android.content.Context import android.content.Intent import android.net.Uri import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleOrigin @@ -18,8 +18,10 @@ import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.txt /** @@ -122,7 +124,9 @@ class CloudStreamPackage : OpenInAppAction( originalName = name ?: "Unknown", headers = headers, origin = SubtitleOrigin.URL, - languageCode = null, + languageCode = fromCodeToLangTagIETF(name) ?: + fromLanguageToTagIETF(name, true) ?: + name, ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt index 102f0ac8b..faae39212 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent -import android.net.Uri import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.updateDurationAndPosition @@ -45,7 +44,7 @@ open class MpvKtPackage( intent.apply { putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) - setDataAndType(Uri.parse(link.url), "video/*") + setDataAndType(link.url.toUri(), "video/*") // m3u8 plays, but changing sources feature is not available // makeTempM3U8Intent(activity, this, result) diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt index 68e619c92..cd49eb994 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent -import android.net.Uri import androidx.core.net.toUri import com.lagradost.api.Log import com.lagradost.cloudstream3.actions.OpenInAppAction @@ -18,6 +17,9 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType // https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 // https://mpv-android.github.io/mpv-android/intent.html +//https://github.com/marlboro-advance/mpvEx +class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity") + class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { override val sourceTypes = setOf( ExtractorLinkType.VIDEO, @@ -26,10 +28,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { ) } -open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction( +open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( txt(appName), packageName, - "is.xyz.mpv.MPVActivity" + intentClass ) { override val oneSource = true // mpv has poor playlist support on TV override suspend fun putExtra( @@ -44,7 +46,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv putExtra("title", video.name) if (index != null) { - setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*") + setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") } else { makeTempM3U8Intent(context, this, result) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt new file mode 100644 index 000000000..e8bb93a99 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.api.Log +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/Riteshp2001/mpvRx + * + * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132 + * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56 + * */ +class MpvRxPackage : OpenInAppAction( + appName = txt("mpvRx"), + packageName = "app.gyrolet.mpvrx", + intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity" +) { + override val oneSource = true + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.apply { + putExtra("title", video.name) + val link = result.links[index!!] + val headers = link.headers + + setData(link.url.toUri()) + if (headers.isNotEmpty()) { + // PlayerActivity expects a flat array: [key1, value1, key2, value2, ...] + val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray() + intent.putExtra("headers", flat) + } + /*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146 + intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray()) + intent.putExtra( + "subs.titles", + subs.map { it.name }.toTypedArray(), + ) + intent.putExtra( + "subs.langs", + subs.map { it.languageCode }.toTypedArray(), + ) + val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri() + intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf() )*/ + + if (video.tvType.isEpisodeBased()) { + video.season?.let { intent.putExtra("introdb_season", it) } + video.episode.let { intent.putExtra("introdb_episode", it) } + } + + val position = getViewPos(video.id)?.position + if (position != null) + putExtra("position", position.toInt()) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getIntExtra("position", -1) ?: -1 + val duration = intent?.getIntExtra("duration", -1) ?: -1 + Log.d("MPV", "Position: $position, Duration: $duration") + updateDurationAndPosition(position.toLong(), duration.toLong()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt new file mode 100644 index 000000000..348be440a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt @@ -0,0 +1,44 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/Kindness-Kismet/only_player/tree/main + * https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */ +class OnlyPlayer : OpenInAppAction( + txt("Only Player"), + "one.only.player", + intentClass = "one.only.player.feature.player.PlayerActivity" +) { + override val oneSource = true + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + /** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */ + intent.apply { + val link = result.links[index!!] + setData(link.url.toUri()) + + putExtra("headers", Bundle().apply { + for ((key, value) in link.headers) { + putExtra(key, value) + } + }) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + /* onResult does not get called */ + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt index 7c1b68c05..bfd2926bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt @@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp import android.content.Context import android.content.Intent -import android.net.Uri +import androidx.core.net.toUri import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult @@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() { ) { val link = result.links.getOrNull(index ?: 0) ?: return val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(link.url) + i.data = link.url.toUri() launch(i) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt new file mode 100644 index 000000000..56512377b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt @@ -0,0 +1,65 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.VideoGenerator +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.txt + +class PlayMirrorAction : VideoClickAction() { + override val name = txt(R.string.episode_action_play_mirror) + + override val oneSource = true + + override val isPlayer = true + + override val sourceTypes: Set = LOADTYPE_INAPP + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + //Implemented a generator to handle the single + val activity = context as? Activity ?: return + val link = index?.let { result.links[it] } + val generatorMirror = object : VideoGenerator(listOf(video)) { + override val hasCache: Boolean = false + override val canSkipLoading: Boolean = false + override fun getId(index: Int): Int = video.id + + override suspend fun generateLinks( + clearCache: Boolean, + sourceTypes: Set, + callback: (Pair) -> Unit, + subtitleCallback: (SubtitleData) -> Unit, + offset: Int, + isCasting: Boolean + ): Boolean { + index?.let { callback(link to null) } + result.subs.forEach { subtitle -> subtitleCallback(subtitle) } + return true + } + } + + activity.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + generatorMirror, 0, result.syncData + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt index e1fc22d3c..46b46a2c2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt @@ -6,7 +6,7 @@ import android.content.Intent import android.os.Build import androidx.core.net.toUri import com.lagradost.api.Log -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.updateDurationAndPosition diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt index 9f7eee7b8..963221bb3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import androidx.core.net.toUri import com.lagradost.cloudstream3.USER_AGENT @@ -38,7 +37,7 @@ class WebVideoCastPackage: OpenInAppAction( val link = result.links[index ?: 0] intent.apply { - setDataAndType(Uri.parse(link.url), "video/*") + setDataAndType(link.url.toUri(), "video/*") val title = video.name ?: video.headerName diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt index e3916df01..1036a7055 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.actions.temp.fcast import android.content.Context -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.actions.VideoClickAction diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt index 282ef834e..e2cf4f002 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt @@ -7,6 +7,7 @@ import android.net.nsd.NsdServiceInfo import android.os.Build import android.os.ext.SdkExtensions import android.util.Log +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe class FcastManager { @@ -72,52 +73,66 @@ class FcastManager { } override fun onServiceFound(serviceInfo: NsdServiceInfo?) { - if (serviceInfo == null) return + // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback + safe { + if (serviceInfo == null) return@safe - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU) >= 7) { - nsdManager?.registerServiceInfoCallback(serviceInfo, - Runnable::run, - object : NsdManager.ServiceInfoCallback { - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.e(tag, "Service registration failed: $errorCode") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU + ) >= 7 + ) { + nsdManager?.registerServiceInfoCallback( + serviceInfo, + Runnable::run, + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.e(tag, "Service registration failed: $errorCode") + } + + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.d( + tag, + "Service updated: ${serviceInfo.serviceName}," + + "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" + ) + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + } + + override fun onServiceLost() { + Log.d(tag, "Service lost: ${serviceInfo.serviceName},") + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + } + } + + override fun onServiceInfoCallbackUnregistered() {} + }) + } else { + @Suppress("DEPRECATION") + nsdManager?.resolveService(serviceInfo, object : ResolveListener { + override fun onResolveFailed( + serviceInfo: NsdServiceInfo?, + errorCode: Int + ) { } - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.d(tag, - "Service updated: ${serviceInfo.serviceName}," + - "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" - ) + + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } _currentDevices.add(PublicDeviceInfo(serviceInfo)) } + + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) } - override fun onServiceLost() { - Log.d(tag, "Service lost: ${serviceInfo.serviceName},") - synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } - } - } - override fun onServiceInfoCallbackUnregistered() {} }) - } else { - @Suppress("DEPRECATION") - 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}" - ) - } - }) + } } } @@ -168,8 +183,9 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { val host: String? = if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU) >= 7 - ) { + Build.VERSION_CODES.TIRAMISU + ) >= 7 + ) { serviceInfo.hostAddresses.firstOrNull()?.hostAddress } else { @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt index 3df5197cd..482ec05fc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt @@ -1,16 +1,68 @@ package com.lagradost.cloudstream3.mvvm +import android.view.View +import androidx.activity.ComponentActivity +import androidx.core.view.doOnAttach import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.ui.BaseFragment /** 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) } } +fun ComponentActivity.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } } /** NOTE: Only one observer at a time per value */ -fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { +fun ComponentActivity.observeNullable(liveData: LiveData, action: (T?) -> Unit) { liveData.removeObservers(this) - liveData.observe(this) { action(it) } + liveData.observe(this, action) } + +/** NOTE: Only one observer at a time per value */ +fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** + * Attaches an observable to the root binding, instead of the fragment. This is more efficient as + * it will not call observe if the view is in the background. + * + * NOTE: Only one observer at a time per value + * */ +fun BaseFragment.observeNullable( + liveData: LiveData, action: (T?) -> Unit +) { + val root = this.binding?.root + if (root == null) { + liveData.removeObservers(this) + liveData.observe(this, action) + } else { + root.doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case + val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable + liveData.removeObservers(owner) + liveData.observe(owner, action) + } + } +} + +/** NOTE: Only one observer at a time per value */ +fun View.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** NOTE: Only one observer at a time per value */ +fun View.observeNullable(liveData: LiveData, action: (T?) -> Unit) { + doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null + val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner() + if(owner == null) { + debugException { "Expected non-null findViewTreeLifecycleOwner" } + return@doOnAttach + } + liveData.removeObservers(owner) + liveData.observe(owner, action) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index ec486d61d..6234297d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.network import android.content.Context import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.mvvm.safe @@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt import java.io.File import java.security.Security +// Backwards compatible constructor, mark as deprecated later fun Requests.initClient(context: Context) { this.baseClient = buildDefaultClient(context) } +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) { + this.baseClient = buildDefaultClient(context, ignoreSSL) +} + + +// Backwards compatible constructor, mark as deprecated later fun buildDefaultClient(context: Context): OkHttpClient { + return buildDefaultClient(context, false) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient { safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient { val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .ignoreAllSSLErrors() + .apply { + if (ignoreSSL) { + ignoreAllSSLErrors() + } + } .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. @@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient { return baseClient } -//val Request.cookies: Map -// get() { -// return this.headers.getCookies("Cookie") -// } - private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** 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 efa028d14..e1496db06 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -7,7 +7,6 @@ import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import kotlin.Throws - abstract class Plugin : BasePlugin() { /** * Called when your Plugin is loaded @@ -26,9 +25,7 @@ abstract class Plugin : BasePlugin() { fun registerVideoClickAction(element: VideoClickAction) { Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") element.sourcePlugin = this.filename - synchronized(VideoClickActionHolder.allVideoClickActions) { - VideoClickActionHolder.allVideoClickActions.add(element) - } + VideoClickActionHolder.allVideoClickActions.add(element) } /** @@ -40,4 +37,4 @@ abstract class Plugin : BasePlugin() { * This will add a button in the settings allowing you to add custom settings */ var openSettings: ((context: Context) -> Unit)? = null -} \ No newline at end of file +} 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 1cffa7c1b..debd3f0eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.annotation.WorkerThread import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -20,15 +21,17 @@ import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.removePluginMapping -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.AllLanguagesName import com.lagradost.cloudstream3.AutoDownloadMode +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.R @@ -43,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins +import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256 import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -51,7 +55,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader @@ -76,6 +80,7 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { + @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -90,7 +95,9 @@ data class PluginData( null, null, null, - File(this.filePath).length() + File(this.filePath).length(), + // No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute. + null ) } } @@ -258,12 +265,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { assertNonRecursiveCallstack() @@ -304,6 +307,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -339,12 +343,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( activity: Activity, @@ -419,6 +419,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -453,12 +454,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { assertNonRecursiveCallstack() @@ -479,13 +476,9 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") + @Suppress("FunctionName") + @InternalAPI @Throws - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) { assertNonRecursiveCallstack() @@ -504,12 +497,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { assertNonRecursiveCallstack() @@ -572,6 +561,11 @@ object PluginManager { afterPluginsLoadedEvent.invoke(forceReload) } + /** @return true if safe mode is enabled in any possible way. */ + fun isSafeMode(): Boolean { + return checkSafeModeFile() || lastError != null + } + /** * This can be used to override any extension loading to fix crashes! * @return true if safe mode file is present @@ -616,7 +610,7 @@ object PluginManager { return false } InputStreamReader(stream).use { reader -> - manifest = parseJson(reader, BasePlugin.Manifest::class.java) + manifest = parseJson(reader.readText()) } } @@ -657,9 +651,15 @@ object PluginManager { context.resources.configuration ) } - plugins[filePath] = pluginInstance - classLoaders[loader] = pluginInstance - urlPlugins[data.url ?: filePath] = pluginInstance + synchronized(plugins) { + plugins[filePath] = pluginInstance + } + synchronized(classLoaders) { + classLoaders[loader] = pluginInstance + } + synchronized(urlPlugins) { + urlPlugins[data.url ?: filePath] = pluginInstance + } if (pluginInstance is Plugin) { pluginInstance.load(context) } else { @@ -695,25 +695,33 @@ object PluginManager { } // remove all registered apis - synchronized(APIHolder.apis) { - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { - removePluginMapping(it) - } - } - synchronized(APIHolder.allProviders) { - APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { + removePluginMapping(it) } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } - - synchronized(VideoClickActionHolder.allVideoClickActions) { - VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } + APIHolder.allProviders.withLock { + APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename } } - classLoaders.values.removeIf { v -> v == plugin } + extractorApis.withLock { + extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename } + } - plugins.remove(absolutePath) - urlPlugins.values.removeIf { v -> v == plugin } + VideoClickActionHolder.allVideoClickActions.withLock { + VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename } + } + + synchronized(classLoaders) { + classLoaders.values.removeIf { v -> v == plugin } + } + + synchronized(plugins) { + plugins.remove(absolutePath) + } + + synchronized(urlPlugins) { + urlPlugins.values.removeIf { v -> v == plugin } + } } /** @@ -743,25 +751,27 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean + loadPlugin: Boolean, ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(pluginUrl, file) ?: return false + val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val data = PluginData( internalName, @@ -808,13 +818,9 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") + @Suppress("FunctionName") + @InternalAPI @Throws - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { assertNonRecursiveCallstack() @@ -853,6 +859,7 @@ object PluginManager { if (downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, existingFile, true @@ -951,4 +958,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 82537ccbc..07d6aaa37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,10 +1,11 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread 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.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app @@ -18,10 +19,12 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream import java.io.File -import java.io.InputStream -import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger /** * Comes with the app, always available in the app, non removable. @@ -62,10 +65,12 @@ data class SitePlugin( @JsonProperty("repositoryUrl") val repositoryUrl: String?, // These types are yet to be mapped and used, ignore for now @JsonProperty("tvTypes") val tvTypes: List?, + // Most often a language tag like "en" or "zh-TW" @JsonProperty("language") val language: String?, @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, + @JsonProperty("fileHash") val fileHash: String?, ) @@ -74,7 +79,26 @@ 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_.-]+)/(.*)$") + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + + /** Returns a SHA-256 string of the file content. + * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ + @WorkerThread + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var read = fis.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = fis.read(buffer) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -139,21 +163,52 @@ object RepositoryManager { }.flatten() } + suspend fun downloadPluginToFile( + context: Context, pluginUrl: String, - file: File + file: File, + expectedFileHash: String? ): File? { return safeAsync { - file.mkdirs() + val parentDir = file.parentFile ?: return@safeAsync null + parentDir.mkdirs() - // Overwrite if exists - if (file.exists()) { - file.delete() - } - file.createNewFile() + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - write(body.byteStream(), file.outputStream()) + + body.byteStream().use { body -> + tempFile.outputStream().use { fileSteam -> + body.copyTo(fileSteam) + } + } + + if (expectedFileHash != null) { + val downloadHash = sha256(tempFile) + if (expectedFileHash != downloadHash) { + tempFile.delete() + throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") + } + } + + // We prefer the operation to be atomic + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + file } } @@ -201,13 +256,4 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } - - private fun write(stream: InputStream, output: OutputStream) { - val input = BufferedInputStream(stream) - val dataBuffer = ByteArray(512) - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - output.write(dataBuffer, 0, readBytes) - } - } } 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 d1b702f4c..85a806f0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins import android.util.Log import android.widget.Toast -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.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import java.security.MessageDigest import com.lagradost.cloudstream3.app @@ -12,87 +12,76 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -object VotingApi { // please do not cheat the votes lol +object VotingApi { + private const val LOGKEY = "VotingApi" + private const val API_DOMAIN = "https://api.countify.xyz" - private const val API_DOMAIN = "https://counterapi.com/api" - - private fun transformUrl(url: String): String = // dont touch or all votes get reset + private fun transformUrl(url: String): String = MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } - suspend fun SitePlugin.getVotes(): Int { - return getVotes(url) - } + suspend fun SitePlugin.getVotes(): Int = getVotes(url) + fun SitePlugin.hasVoted(): Boolean = hasVoted(url) + suspend fun SitePlugin.vote(): Int = vote(url) + fun SitePlugin.canVote(): Boolean = canVote(this.url) - fun SitePlugin.hasVoted(): Boolean { - return hasVoted(url) - } - - suspend fun SitePlugin.vote(): Int { - return vote(url) - } - - fun SitePlugin.canVote(): Boolean { - return canVote(this.url) - } - - // 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" - Log.d(LOGKEY, "Requesting: $url") - return app.get(url).parsedSafe()?.value ?: 0 + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/get-total/$id" + Log.d(LOGKEY, "Requesting GET: $url") + return app.get(url).parsedSafe()?.count ?: 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 + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/increment/$id" + Log.d(LOGKEY, "Requesting POST: $url") + return app.post(url, emptyMap()) + .parsedSafe()?.count != null } suspend fun getVotes(pluginUrl: String): Int = - votesCache[pluginUrl] ?: readVote(pluginUrl).also { - votesCache[pluginUrl] = it - } + votesCache[pluginUrl] ?: readVote(pluginUrl).also { + votesCache[pluginUrl] = it + } fun hasVoted(pluginUrl: String) = getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false - fun canVote(pluginUrl: String): Boolean { - return PluginManager.urlPlugins.contains(pluginUrl) - } + fun canVote(pluginUrl: String): Boolean = + PluginManager.urlPlugins.contains(pluginUrl) private val voteLock = Mutex() + suspend fun vote(pluginUrl: String): Int { - // Prevent multiple requests at the same time. voteLock.withLock { if (!canVote(pluginUrl)) { main { - Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT) - .show() + Toast.makeText( + context, + R.string.extension_install_first, + Toast.LENGTH_SHORT + ).show() } return getVotes(pluginUrl) } if (hasVoted(pluginUrl)) { main { - Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) - .show() + Toast.makeText( + context, + R.string.already_voted, + Toast.LENGTH_SHORT + ).show() } return getVotes(pluginUrl) } - if (writeVote(pluginUrl)) { setKey("cs3-votes/${transformUrl(pluginUrl)}", true) votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 @@ -102,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol } } - private data class Result( - val value: Int? + private data class CountifyResult( + val id: String? = null, + val count: Int? = null ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt new file mode 100644 index 000000000..e07747a86 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -0,0 +1,279 @@ +package com.lagradost.cloudstream3.services + +import android.Manifest +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.MainActivity.Companion.setLastError +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.system.measureTimeMillis +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class DownloadQueueService : Service() { + companion object { + const val TAG = "DownloadQueueService" + const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" + const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" + const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." + const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique + @Volatile + var isRunning = false + + fun getIntent( + context: Context, + ): Intent { + return Intent(context, DownloadQueueService::class.java) + } + + private val _downloadInstances: MutableStateFlow> = + MutableStateFlow(emptyList()) + + /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. + * Completed or failed instances are automatically removed by the download queue service. + * + */ + val downloadInstances: StateFlow> = + _downloadInstances + + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + instances to queue + } + .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> + Triple(instances, queue, currentDownloads) + } + } + + + private val baseNotification by lazy { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = + PendingIntentCompat.getActivity(this, 0, intent, 0, false) + + val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) + val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) + + NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) + .setOngoing(true) // Make it persistent + .setAutoCancel(false) + .setColorized(false) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + // If low priority then the notification might not show :( + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(this.colorFromAttribute(R.attr.colorPrimary)) + .setContentText(activeDownloads) + .setSubText(activeQueue) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.download_icon_load) + } + + + private fun updateNotification(context: Context, downloads: Int, queued: Int) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + + val activeDownloads = + resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) + val activeQueue = + resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) + + val newNotification = baseNotification + .setContentText(activeDownloads) + .setSubText(activeQueue) + .build() + + safe { + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) + } + } + + // We always need to listen to events, even before the download is launched. + // Stopping link loading is an event which can trigger before downloading. + val downloadEventListener = { event: Pair -> + when (event.second) { + VideoDownloadManager.DownloadActionType.Stop -> { + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) + DownloadQueueManager.cancelDownload(event.first) + } + + else -> {} + } + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + override fun onCreate() { + isRunning = true + val context: Context = this // To make code more readable + + Log.d(TAG, "Download queue service started.") + this.createNotificationChannel( + DOWNLOAD_QUEUE_CHANNEL_ID, + DOWNLOAD_QUEUE_CHANNEL_NAME, + DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION + ) + if (SDK_INT >= 29) { + startForeground( + DOWNLOAD_QUEUE_NOTIFICATION_ID, + baseNotification.build(), + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) + } + + downloadEvent += downloadEventListener + + val queueJob = ioSafe { + // Ensure this is up to date to prevent race conditions with MainActivity launches + setLastError(context) + // Early return, to prevent waiting for plugins in safe mode + if (lastError != null) return@ioSafe + + // Try to ensure all plugins are loaded before starting the downloader. + // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough + val timeout = 15.seconds + val timeTaken = withTimeoutOrNull(timeout) { + measureTimeMillis { + while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { + delay(100.milliseconds) + } + } + } + + debugWarning({ timeTaken == null || timeTaken > 3_000 }, { + "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" + }) + debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) + + totalDownloadFlow + .debounce { (instances, queue) -> + // Filter away incorrect transient queue states. + // For example when we pop the queue and add a download instance there exists a transient state where + // there is no queue and no download instances (leading to an early exit) + if (instances.isEmpty() && queue.isEmpty()) { + 500.milliseconds + } else { + 0.milliseconds + } + } + .takeWhile { (instances, queue) -> + // Stop if destroyed + isRunning + // Run as long as there is a queue to process + && (instances.isNotEmpty() || queue.isNotEmpty()) + // Run as long as there are no app crashes + && lastError == null + } + .collect { (_, queue, currentDownloads) -> + // Remove completed or failed + val newInstances = _downloadInstances.updateAndGet { currentInstances -> + currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } + } + + val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) + val currentInstanceCount = newInstances.size + + val newDownloads = minOf( + // Cannot exceed the max downloads + maxOf(0, maxDownloads - currentInstanceCount), + // Cannot start more downloads than the queue size + queue.size + ) + + // Cant start multiple downloads at once. If this is rerun it may start too many downloads. + if (newDownloads > 0) { + _downloadInstances.update { instances -> + val downloadInstance = DownloadQueueManager.popQueue(context) + if (downloadInstance != null) { + downloadInstance.startDownload() + instances + downloadInstance + } else { + instances + } + } + } + + // The downloads actually displayed to the user with a notification + val currentVisualDownloads = + currentDownloads.size + newInstances.count { + currentDownloads.contains(it.downloadQueueWrapper.id) + .not() + } + // Just the queue + val currentVisualQueue = queue.size + + updateNotification(context, currentVisualDownloads, currentVisualQueue) + } + } + + // Stop self regardless of job outcome + queueJob.invokeOnCompletion { throwable -> + if (throwable != null) { + logError(throwable) + } + safe { + stopSelf() + } + } + } + + override fun onDestroy() { + Log.d(TAG, "Download queue service stopped.") + downloadEvent -= downloadEventListener + isRunning = false + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY // We want the service restarted if its killed + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTimeout(reason: Int) { + stopSelf() + Log.e(TAG, "Service stopped due to timeout: $reason") + } + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt index fc31c1f3e..7134650ed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -22,7 +22,7 @@ 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 com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.TimeUnit @@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete .build() ) } - @Suppress("DEPRECATION_ERROR") + override suspend fun doWork(): Result { try { // println("Update subscriptions!") 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 6151a0edd..d63b18cdc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Intent import android.os.IBinder -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +/** Handle notification actions such as pause/resume downloads */ class VideoDownloadService : Service() { private val downloadScope = CoroutineScope(Dispatchers.Default) @@ -42,19 +43,3 @@ class VideoDownloadService : Service() { super.onDestroy() } } -// override fun onHandleIntent(intent: Intent?) { -// if (intent != null) { -// val id = intent.getIntExtra("id", -1) -// val type = intent.getStringExtra("type") -// if (id != -1 && type != null) { -// val state = when (type) { -// "resume" -> VideoDownloadManager.DownloadActionType.Resume -// "pause" -> VideoDownloadManager.DownloadActionType.Pause -// "stop" -> VideoDownloadManager.DownloadActionType.Stop -// else -> return -// } -// VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) -// } -// } -// } -//} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 20a0b6446..3bc5f2733 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,10 +1,11 @@ package com.lagradost.cloudstream3.syncproviders -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi @@ -12,12 +13,14 @@ import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth import java.util.concurrent.TimeUnit abstract class AccountManager { companion object { const val NONE_ID: Int = -1 val malApi = MALApi() + val kitsuApi = KitsuApi() val aniListApi = AniListApi() val simklApi = SimklApi() val localListApi = LocalList() @@ -26,6 +29,7 @@ abstract class AccountManager { val addic7ed = Addic7ed() val subDlApi = SubDlApi() val subSourceApi = SubSourceApi() + val animeSkipApi = AnimeSkipAuth() var cachedAccounts: MutableMap> var cachedAccountIds: MutableMap @@ -59,14 +63,14 @@ abstract class AccountManager { val allApis = arrayOf( SyncRepo(malApi), + SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi), - SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), SubtitleRepo(subDlApi), - SubtitleRepo(subSourceApi) + PlainAuthRepo(animeSkipApi) ) fun updateAccountIds() { @@ -108,6 +112,7 @@ abstract class AccountManager { // accessing other classes fun initMainAPI() { LoadResponse.malIdPrefix = malApi.idPrefix + LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix } @@ -115,11 +120,11 @@ abstract class AccountManager { val subtitleProviders = arrayOf( SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), - SubtitleRepo(subDlApi), - SubtitleRepo(subSourceApi) + SubtitleRepo(subDlApi) ) val syncApis = arrayOf( SyncRepo(malApi), + SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi) @@ -135,6 +140,8 @@ abstract class AccountManager { // Instantly resume watching a show const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" + const val APP_STRING_SHARE = "csshare" + fun secondsToReadable(seconds: Int, completedValue: String): String { var secondsLong = seconds.toLong() val days = TimeUnit.SECONDS diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 457efce99..b6997d494 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -1,52 +1,14 @@ package com.lagradost.cloudstream3.syncproviders -import android.util.Base64 -import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.unixTime -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.ActorData -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.NextAiring -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.SearchQuality -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.ShowStatus -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch -import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.base64Encode import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID -import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi -import com.lagradost.cloudstream3.syncproviders.providers.LocalList -import com.lagradost.cloudstream3.syncproviders.providers.MALApi -import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi -import com.lagradost.cloudstream3.syncproviders.providers.SimklApi -import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi -import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi -import com.lagradost.cloudstream3.ui.SyncWatchType -import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.txt -import me.xdrop.fuzzywuzzy.FuzzySearch -import java.net.URL +import java.net.URI import java.security.SecureRandom -import java.util.Date -import java.util.concurrent.TimeUnit data class AuthLoginPage( /** The website to open to authenticate */ @@ -83,10 +45,10 @@ data class AuthToken( val payload: String? = null, ) { fun isAccessTokenExpired(marginSec: Long = 10L) = - accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime + accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime fun isRefreshTokenExpired(marginSec: Long = 10L) = - refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime + refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime } data class AuthUser( @@ -181,16 +143,33 @@ abstract class AuthAPI { open val inAppLoginRequirement: AuthLoginRequirement? = null companion object { + @Deprecated( + message = "Use APIHolder.unixTime instead", + replaceWith = ReplaceWith( + expression = "APIHolder.unixTime", + imports = ["com.lagradost.cloudstream3.APIHolder"] + ), + level = DeprecationLevel.WARNING, + ) val unixTime: Long - get() = System.currentTimeMillis() / 1000L + get() = APIHolder.unixTime + + @Deprecated( + message = "Use APIHolder.unixTimeMS instead", + replaceWith = ReplaceWith( + expression = "unixTimeMS", + imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"] + ), + level = DeprecationLevel.WARNING, + ) val unixTimeMs: Long - get() = System.currentTimeMillis() + get() = unixTimeMS fun splitRedirectUrl(redirectUrl: String): Map { return splitQuery( - URL( + URI( redirectUrl.replace(APP_STRING, "https").replace("/#", "?") - ) + ).toURL() ) } @@ -200,9 +179,8 @@ abstract class AuthAPI { val secureRandom = SecureRandom() val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 secureRandom.nextBytes(codeVerifierBytes) - return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=') - .replace("+", "-") - .replace("/", "_").replace("\n", "") + return base64Encode(codeVerifierBytes).trimEnd('=') + .replace("+", "-").replace("/", "_").replace("\n", "") } } @@ -250,14 +228,15 @@ abstract class AuthAPI { open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() @Throws - @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) fun toRepo(): AuthRepo = when (this) { is SubtitleAPI -> SubtitleRepo(this) is SyncAPI -> SyncRepo(this) else -> throw NotImplementedError("Unknown inheritance from AuthAPI") } - @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) + @Suppress("DEPRECATION_ERROR") + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) fun loginInfo(): LoginInfo? { return this.toRepo().authUser()?.let { user -> LoginInfo( @@ -268,19 +247,16 @@ abstract class AuthAPI { } } - @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + @Suppress("DEPRECATION_ERROR") return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow() } - @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) class LoginInfo( val profilePicture: String? = null, val name: String?, val accountIndex: Int, ) } - - - - diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt index 9444c6367..645a19e3a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.syncproviders -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R @@ -9,6 +9,9 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID import com.lagradost.cloudstream3.utils.txt +/** General-purpose repo */ +class PlainAuthRepo(api: AuthAPI) : AuthRepo(api) + /** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ abstract class AuthRepo(open val api: AuthAPI) { fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt index e831fb3e8..0b8c3e5ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.SubtitleResource -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf /** Stateless safe abstraction of SubtitleAPI */ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { @@ -24,26 +24,30 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { ) // maybe make this a generic struct? right now there is a lot of boilerplate - private val searchCache = threadSafeListOf() + private val searchCache = atomicListOf() private var searchCacheIndex: Int = 0 - private val resourceCache = threadSafeListOf() + private val resourceCache = atomicListOf() private var resourceCacheIndex: Int = 0 const val CACHE_SIZE = 20 } @WorkerThread suspend fun resource(data: SubtitleEntity): Result = runCatching { - synchronized(resourceCache) { + val cached = resourceCache.withLock { + var found: SubtitleResource? = null for (item in resourceCache) { // 20 min save if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { - return@runCatching item.response + found = item.response + break } } + found } + if (cached != null) return@runCatching cached val returnValue = api.resource(freshAuth(), data) - synchronized(resourceCache) { + resourceCache.withLock { val add = SavedResourceResponse(unixTime, returnValue, data) if (resourceCache.size > CACHE_SIZE) { resourceCache[resourceCacheIndex] = add // rolling cache @@ -58,22 +62,25 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { @WorkerThread suspend fun search(query: SubtitleSearch): Result> { return runCatching { - synchronized(searchCache) { + val cached = searchCache.withLock { + var found: List? = null for (item in searchCache) { // 120 min save if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { - return@runCatching item.response + found = item.response + break } } + found } - val returnValue = - api.search(freshAuth(), query) ?: throw ErrorLoadingException("Null subtitles") + if (cached != null) return@runCatching cached + val returnValue = api.search(freshAuth(), query) ?: emptyList() // only cache valid return values if (returnValue.isNotEmpty()) { val add = SavedSearchResponse(unixTime, returnValue, query) - synchronized(searchCache) { + searchCache.withLock { if (searchCache.size > CACHE_SIZE) { searchCache[searchCacheIndex] = add // rolling cache searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE @@ -86,4 +93,3 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { } } } - diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index e5f9aca84..f30a64748 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.Levenshtein import com.lagradost.cloudstream3.utils.UiText -import me.xdrop.fuzzywuzzy.FuzzySearch import java.util.Date /** @@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() { ListSorting.Query -> if (query != null) { items.sortedBy { - -FuzzySearch.partialRatio( + -Levenshtein.partialRatio( query.lowercase(), it.name.lowercase() ) } @@ -191,4 +191,4 @@ abstract class SyncAPI : AuthAPI() { override var score: Score? = null, val tags: List? = null ) : SearchResponse -} \ No newline at end of file +} 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 5f71ac9a1..144efff99 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 @@ -1,16 +1,17 @@ package com.lagradost.cloudstream3.syncproviders.providers -import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SubtitleAPI -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName class Addic7ed : SubtitleAPI() { override val name = "Addic7ed" override val idPrefix = "addic7ed" - override val requiresLogin = false companion object { @@ -18,7 +19,8 @@ class Addic7ed : SubtitleAPI() { const val TAG = "ADDIC7ED" } - private fun fixUrl(url: String): String { + private fun String.fixUrl(): String { + val url = this return if (url.startsWith("/")) HOST + url else if (!url.startsWith("http")) "$HOST/$url" else url @@ -26,84 +28,178 @@ class Addic7ed : SubtitleAPI() { override suspend fun search( auth: AuthData?, - query: AbstractSubtitleEntities.SubtitleSearch - ): List? { - val lang = query.lang - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) - val queryText = query.query.trim() + query: SubtitleSearch + ): List? { + val langTagIETF = query.lang ?: AllLanguagesName + val langNumAddic7ed = + langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0 + val langName = + langTagIETF2Addic7ed[langTagIETF]?.second ?: + fromTagToEnglishLanguageName(langTagIETF) ?: + "Completed" // this bypasses language filtering + val title = query.query.trim() val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 + val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title + var downloadPage = "" - fun cleanResources( - results: MutableList, - name: String, - link: String, - headers: Map, + fun newSubtitleEntity ( + displayName: String?, + link: String?, isHearingImpaired: Boolean - ) { - 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, - headers = headers, - isHearingImpaired = isHearingImpaired - ) + ): SubtitleEntity? { + if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null + return SubtitleEntity( + idPrefix = this.idPrefix, + name = displayName, + lang = langTagIETF, + data = link, + source = this.name, + type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, + epNumber = epNum, + seasonNumber = seasonNum, + year = yearNum, + headers = mapOf("referer" to "$HOST/"), + isHearingImpaired = isHearingImpaired ) } - val title = queryText.substringBefore("(").trim() - val url = "$HOST/search.php?search=${title}&Submit=Search" - val hostDocument = app.get(url).document - var searchResult = "" - if (hostDocument.select("span:contains($title)").isNotEmpty()) searchResult = url - else if (hostDocument.select("table.tabel") - .isNotEmpty() - ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString() - else { - val show = - hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") - ?.substringBefore(",") + val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") + val hostDocument = response.document + + // 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name + if (response.url.contains("/movie/") || response.url.contains("/serie/")) + downloadPage = response.url + + // 2nd case: found tv series ep list. Redirected to $HOST/show/1234 + else if (response.url.contains("/show/")) { + val showId = response.url.substringAfterLast("/") val doc = app.get( - "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", + "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", referer = "$HOST/" ).document - doc.select("#season tr:contains($queryLang)").mapNotNull { node -> - if (node.selectFirst("td")?.text() - ?.toIntOrNull() == seasonNum && node.select("td:eq(1)") - .text() - .toIntOrNull() == epNum - ) searchResult = fixUrl(node.select("a").attr("href")) - } - } - val results = mutableListOf() - val document = app.get( - url = fixUrl(searchResult), - ).document - document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node -> - val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${ - node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration") - }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}" - val link = fixUrl(node.select("a.buttonDownload").attr("href")) + // get direct subtitles links from list + return doc.select("#season tbody tr").mapNotNull { node -> + if (node.select("td:eq(1)").text().toIntOrNull() == epNum) + newSubtitleEntity( + displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), + link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(), + isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty() + ) + else null + } + // 3rd case: found several or no results. Still in $HOST/search.php?search=title + } else {// (response.url.contains("/search.php")) + downloadPage = hostDocument.select("table.tabel a").selectFirst({ + // tv series + if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]" + // movie + year + else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)" + // movie + else "a[href~=movie\\/]" + }())?.attr("href")?.fixUrl() ?: return null + } + + // filter download page by language. Do not work for movies :/ + if (downloadPage.contains("/serie/")) + downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed" + val doc = app.get(url = downloadPage).document + + // get subtitles links from download page + return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node -> + val displayName = + doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" + + node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration") + val link = + node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl() val isHearingImpaired = node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() - cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired) + + newSubtitleEntity(displayName, link, isHearingImpaired) } - return results } override suspend fun load( auth: AuthData?, - subtitle: AbstractSubtitleEntities.SubtitleEntity + subtitle: SubtitleEntity ): String? { return subtitle.data } + + // Missing (?_?) + // Pair("2", ""), + // Pair("3", ""), + // Pair("33", ""), + // Pair("34", ""), + // Do not modify unless Addic7ed changes them! + // as they are the exact values from their website + private val langTagIETF2Addic7ed = mapOf( + "ar" to Pair("38", "Arabic"), + "az" to Pair("48", "Azerbaijani"), + "bg" to Pair("35", "Bulgarian"), + "bn" to Pair("47", "Bengali"), + "bs" to Pair("44", "Bosnian"), + "ca" to Pair("12", "Català"), + "cs" to Pair("14", "Czech"), + "cy" to Pair("65", "Welsh"), + "da" to Pair("30", "Danish"), + "de" to Pair("11", "German"), + "el" to Pair("27", "Greek"), + "en" to Pair("1", "English"), + "es-419" to Pair("6", "Spanish (Latin America)"), + "es-ar" to Pair("69", "Spanish (Argentina)"), + "es-es" to Pair("5", "Spanish (Spain)"), + "es" to Pair("4", "Spanish"), + "et" to Pair("54", "Estonian"), + "eu" to Pair("13", "Euskera"), + "fa" to Pair("43", "Persian"), + "fi" to Pair("28", "Finnish"), + "fr-ca" to Pair("53", "French (Canadian)"), + "fr" to Pair("8", "French"), + "gl" to Pair("15", "Galego"), + "he" to Pair("23", "Hebrew"), + "hi" to Pair("55", "Hindi"), + "hr" to Pair("31", "Croatian"), + "hu" to Pair("20", "Hungarian"), + "hy" to Pair("50", "Armenian"), + "id" to Pair("37", "Indonesian"), + "is" to Pair("56", "Icelandic"), + "it" to Pair("7", "Italian"), + "ja" to Pair("32", "Japanese"), + "kn" to Pair("66", "Kannada"), + "ko" to Pair("42", "Korean"), + "lt" to Pair("58", "Lithuanian"), + "lv" to Pair("57", "Latvian"), + "mk" to Pair("49", "Macedonian"), + "ml" to Pair("67", "Malayalam"), + "mr" to Pair("62", "Marathi"), + "ms" to Pair("40", "Malay"), + "nl" to Pair("17", "Dutch"), + "no" to Pair("29", "Norwegian"), + "pl" to Pair("21", "Polish"), + "pt-br" to Pair("10", "Portuguese (Brazilian)"), + "pt" to Pair("9", "Portuguese"), + "ro" to Pair("26", "Romanian"), + "ru" to Pair("19", "Russian"), + "si" to Pair("60", "Sinhala"), + "sk" to Pair("25", "Slovak"), + "sl" to Pair("22", "Slovenian"), + "sq" to Pair("52", "Albanian"), + "sr-latn" to Pair("36", "Serbian (Latin)"), + "sr" to Pair("39", "Serbian (Cyrillic)"), + "sv" to Pair("18", "Swedish"), + "ta" to Pair("59", "Tamil"), + "te" to Pair("63", "Telugu"), + "th" to Pair("46", "Thai"), + "tl" to Pair("68", "Tagalog"), + "tlh" to Pair("61", "Klingon"), + "tr" to Pair("16", "Turkish"), + "uk" to Pair("51", "Ukrainian"), + "vi" to Pair("45", "Vietnamese"), + "yue" to Pair("64", "Cantonese"), + "zh-hans" to Pair("41", "Chinese (Simplified)"), + "zh-hant" to Pair("24", "Chinese (Traditional)"), + ) } \ No newline at end of file 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 a4cd42848..441eb1bf2 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 @@ -2,11 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.Actor import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.R @@ -35,7 +37,7 @@ class AniListApi : SyncAPI() { override var name = "AniList" override val idPrefix = "anilist" - val key = "6871" + private val key = BuildConfig.ANILIST_KEY override val redirectUrlIdentifier = "anilistlogin" override var requireLibraryRefresh = true override val hasOAuth2 = true @@ -50,9 +52,10 @@ class AniListApi : SyncAPI() { override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { val sanitizer = splitRedirectUrl(redirectUrl) val token = AuthToken( - accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), + accessToken = sanitizer["access_token"] + ?: throw ErrorLoadingException("No access token"), //refreshToken = sanitizer["refresh_token"], - accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), + accessTokenLifetime = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(), ) return token } @@ -83,8 +86,8 @@ class AniListApi : SyncAPI() { return "$mainUrl/anime/$id" } - override suspend fun search(auth : AuthData?, query: String): List? { - val data = searchShows(name) ?: return null + override suspend fun search(auth: AuthData?, query: String): List? { + val data = searchShows(query) ?: return null return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, @@ -96,7 +99,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { + override suspend fun load(auth: AuthData?, 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 @@ -106,7 +109,7 @@ class AniListApi : SyncAPI() { nextAiring = season.nextAiringEpisode?.let { NextAiring( it.episode ?: return@let null, - (it.timeUntilAiring ?: return@let null) + unixTime + (it.timeUntilAiring ?: return@let null) + APIHolder.unixTime ) }, title = season.title?.userPreferred, @@ -158,7 +161,7 @@ class AniListApi : SyncAPI() { ) } - override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null val data = getDataAboutId(auth ?: return null, internalId) ?: return null @@ -459,7 +462,7 @@ class AniListApi : SyncAPI() { } } - private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { + private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? { val q = """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) @@ -506,7 +509,7 @@ class AniListApi : SyncAPI() { } - private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { + private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? { return app.post( "https://graphql.anilist.co/", headers = mapOf( @@ -638,7 +641,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> @@ -666,7 +669,7 @@ class AniListApi : SyncAPI() { ) } - private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { + private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? { val userID = auth.user.id val mediaType = "ANIME" @@ -714,7 +717,7 @@ class AniListApi : SyncAPI() { return text?.toKotlinObject() } - suspend fun toggleLike(auth : AuthData, id: Int): Boolean { + suspend fun toggleLike(auth: AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -737,7 +740,7 @@ class AniListApi : SyncAPI() { data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( - auth : AuthData, + auth: AuthData, id: Int, type: AniListStatusType, score: Score?, @@ -786,7 +789,7 @@ class AniListApi : SyncAPI() { return data != "" } - private suspend fun getUser(token : AuthToken): AniListUser? { + private suspend fun getUser(token: AuthToken): AniListUser? { val q = """ { Viewer { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt index 724d72163..9eb49b4bd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -1,8 +1,677 @@ package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +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.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.txt +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.util.Date +import java.util.Locale + +const val KITSU_MAX_SEARCH_LIMIT = 20 + +class KitsuApi: SyncAPI() { + override var name = "Kitsu" + override val idPrefix = "kitsu" + + private val apiUrl = "https://kitsu.io/api/edge" + private val fallbackApiUrl = "https://kitsu.app/api/edge" + private val oauthUrl = "https://kitsu.io/api/oauth" + private val fallbackOauthUrl = "https://kitsu.app/api/oauth" + override val hasInApp = true + override val mainUrl = "https://kitsu.app" + override val icon = R.drawable.kitsu_icon + override val syncIdName = SyncIdName.Kitsu + override val createAccountUrl = mainUrl + + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) + + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + email = true + ) + + private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + + try { + + val response = chain.proceed(request); + + if (response.isSuccessful) return response + + response.close() + + } catch (_: Exception) { + } + + val fallbackRequest: Request = request.newBuilder() + .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) + .build() + + return chain.proceed(fallbackRequest) + + } + } + + private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) + private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.email ?: return null + val password = form.password ?: return null + + val grantType = "password" + + val token = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to grantType, + "username" to username, + "password" to password + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken, + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken { + val res = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong() + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/users?filter[self]=true", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + if (user.data.isEmpty()) { + return null + } + + return AuthUser( + id = user.data[0].id.toInt(), + name = user.data[0].attributes.name, + profilePicture = user.data[0].attributes.avatar?.original + ) + } + + override suspend fun search(auth: AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") + val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth", + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + return res.data.map { + val attributes = it.attributes + + val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" + + SyncSearchResult( + title, + this.name, + it.id, + "$mainUrl/anime/${it.id}/", + attributes.posterImage?.large ?: attributes.posterImage?.medium + ) + } + } + + override suspend fun load(auth : AuthData?, id: String): SyncResult? { + val auth = auth?.token?.accessToken ?: return null + if (id.toIntOrNull() == null) { + return null + } + + data class KitsuResponse( + @field:JsonProperty(value = "data") + val data: KitsuNode, + ) + + val url = + "$apiUrl/anime/$id" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.attributes + + return SyncResult( + id = id, + totalEpisodes = anime.episodeCount, + title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), + publicScore = Score.from(anime.ratingTwenty, 20), + duration = anime.episodeLength, + synopsis = anime.synopsis, + airStatus = when(anime.status) { + "finished" -> ShowStatus.Completed + "current" -> ShowStatus.Ongoing + else -> null + }, + nextAiring = null, + studio = null, + genres = null, + trailers = null, + startDate = LocalDate.parse(anime.startDate).toEpochDay(), + endDate = LocalDate.parse(anime.endDate).toEpochDay(), + recommendations = null, + nextSeason =null, + prevSeason = null, + actors = null, + ) + + } + + override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { + val accessToken = auth?.token?.accessToken ?: return null + val userId = auth.user.id + + val selectedFields = arrayOf("status","ratingTwenty", "progress") + + val url = + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $accessToken" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull()?.attributes + + if (anime == null) { + return SyncStatus( + score = null, + status = SyncWatchType.NONE, + isFavorite = null, + watchedEpisodes = null + ) + } + + return SyncStatus( + score = Score.from(anime.ratingTwenty, 20), + status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), + isFavorite = null, + watchedEpisodes = anime.progress, + ) + } + suspend fun getAnimeIdByTitle(title: String): String? { + + val animeSelectedFields = arrayOf("titles","canonicalTitle") + val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get(url, interceptor = apiFallbackInterceptor).parsed() + + return res.data.firstOrNull()?.id + + } + + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() + + override suspend fun updateStatus( + auth : AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { + + return setScoreRequest( + auth ?: return false, + id.toIntOrNull() ?: return false, + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(20), + newStatus.watchedEpisodes + ) + } + + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: KitsuStatusType? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + + val libraryEntryId = getAnimeLibraryEntryId(auth, id) + + // Exists entry for anime in library + if (libraryEntryId != null) { + + // Delete anime from library + if (status == null || status == KitsuStatusType.None) { + + val res = app.delete( + "$apiUrl/library-entries/$libraryEntryId", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + return setScoreRequest( + auth, + libraryEntryId, + kitsuStatusAsString[maxOf(0, status.value)], + score, + numWatchedEpisodes + ) + + } + + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], + ), + "relationships" to mapOf( + "anime" to mapOf( + "data" to mapOf( + "type" to "anime", + "id" to id.toString() + ) + ), + "user" to mapOf( + "data" to mapOf( + "type" to "users", + "id" to auth.user.id + ) + ) + ) + ) + ) + + val res = app.post( + "$apiUrl/library-entries", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + return res.isSuccessful + + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: String? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "id" to id.toString(), + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to status + ) + ) + ) + + val res = app.patch( + "$apiUrl/library-entries/$id", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { + + val userId = auth.user.id + + val res = app.get( + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull() ?: return null + + return res.id.toInt() + + } + + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { + convertToStatus(it.attributes.status ?: "").stringRes + }?.mapValues { group -> + group.value.map { it.toLibraryItem() } + } ?: emptyMap() + + // To fill empty lists when Kitsu does not return them + val baseMap = + KitsuStatusType.entries.filter { it.value >= 0 }.associate { + it.stringRes to emptyList() + } + + return LibraryMetadata( + (baseMap + list).map { LibraryList(txt(it.key), it.value) }, + setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getKitsuAnimeList(auth.token, auth.user.id) + setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { + + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount") + val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status") + val limit = 500 + var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" + + val fullList = mutableListOf() + + while (true) { + + val data: KitsuResponse = getKitsuAnimeListSlice(token, url) + + data.data.forEachIndexed { index, value -> + value.anime = data.included?.get(index) + } + + fullList.addAll(data.data) + + url = data.links?.next ?: break + } + + + return fullList.toTypedArray() + } + + private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + ), + interceptor = apiFallbackInterceptor + ).parsed() + return res + } + + + 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, + ) + + data class KitsuNode( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuNodeAttributes, + /* User list anime node */ + @JsonProperty("relationships") val relationships: KitsuRelationships?, + var anime: KitsuAnimeData? + ) { + fun toLibraryItem(): LibraryItem { + + val animeItem = this.anime + + val numEpisodes = animeItem?.attributes?.episodeCount + + val startDate = animeItem?.attributes?.startDate + + val posterImage = animeItem?.attributes?.posterImage + + val canonicalTitle = animeItem?.attributes?.canonicalTitle + val titles = animeItem?.attributes?.titles + + val animeId = animeItem?.id + + val synopsis: String? = animeItem?.attributes?.synopsis + + return LibraryItem( + canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), + "https://kitsu.app/anime/${animeId}/", + this.id, + this.attributes.progress, + numEpisodes, + Score.from(this.attributes.ratingTwenty, 20), + parseDateLong(this.attributes.updatedAt), + "Kitsu", + TvType.Anime, + posterImage?.large ?: posterImage?.medium, + null, + null, + plot = synopsis, + releaseDate = if (startDate == null) null else try { + Date.from(LocalDate.parse(startDate).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant()) + } catch (_: RuntimeException) { + null + } + ) + } + + } + + data class KitsuAnimeAttributes( + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + ) + + data class KitsuAnimeData( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, + ) + + + data class KitsuNodeAttributes( + /* General attributes */ + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + /* User attributes */ + @JsonProperty("name") val name: String?, + @JsonProperty("location") val location: String?, + @JsonProperty("createdAt") val createdAt: String?, + @JsonProperty("avatar") val avatar: KitsuUserAvatar?, + /* User list anime attributes */ + @JsonProperty("progress") val progress: Int?, + @JsonProperty("ratingTwenty") val ratingTwenty: Int?, + @JsonProperty("updatedAt") val updatedAt: String?, + @JsonProperty("status") val status: String?, + ) + + data class KitsuRelationships( + @JsonProperty("anime") val anime: KitsuRelationshipsAnime? + ) + + data class KitsuRelationshipsAnime( + @JsonProperty("links") val links: KitsuLinks? + ) + + data class KitsuPosterImage( + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, + ) + + data class KitsuTitles( + @JsonProperty("en_jp") val enJp: String?, + @JsonProperty("ja_jp") val jaJp: String? + ) + + data class KitsuUserAvatar( + @JsonProperty("original") val original: String? + ) + + data class KitsuLinks( + /* Pagination */ + @JsonProperty("first") val first: String?, + @JsonProperty("next") val next: String?, + @JsonProperty("last") val last: String?, + /* Relationships */ + @JsonProperty("related") val related: String? + ) + + data class KitsuResponse( + @JsonProperty("links") val links: KitsuLinks?, + @JsonProperty("data") val data: List, + /* When requesting related info (User library entry -> anime) */ + @JsonProperty("included") val included: List?, + ) + + + companion object { + + const val KITSU_CACHED_LIST: String = "kitsu_cached_list" + private fun parseDateLong(string: String?): Long? { + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse( + string ?: return null + )?.time?.div(1000) + } catch (e: Exception) { + null + } + } + + private val kitsuStatusAsString = + arrayOf("current", "completed", "on_hold", "dropped", "planned") + private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { + return when (inp) { + SyncWatchType.NONE -> KitsuStatusType.None + SyncWatchType.WATCHING -> KitsuStatusType.Watching + SyncWatchType.COMPLETED -> KitsuStatusType.Completed + SyncWatchType.ONHOLD -> KitsuStatusType.OnHold + SyncWatchType.DROPPED -> KitsuStatusType.Dropped + SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch + SyncWatchType.REWATCHING -> KitsuStatusType.Watching + } + } + + enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { + Watching(0, R.string.type_watching), + Completed(1, R.string.type_completed), + OnHold(2, R.string.type_on_hold), + Dropped(3, R.string.type_dropped), + PlanToWatch(4, R.string.type_plan_to_watch), + None(-1, R.string.type_none) + } + + private fun convertToStatus(string: String): KitsuStatusType { + return when (string) { + "current" -> KitsuStatusType.Watching + "completed" -> KitsuStatusType.Completed + "on_hold" -> KitsuStatusType.OnHold + "dropped" -> KitsuStatusType.Dropped + "planned" -> KitsuStatusType.PlanToWatch + else -> KitsuStatusType.None + } + } + } +} // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md @@ -142,4 +811,4 @@ query { val canonical: String? = null ) } -} \ No newline at end of file +} 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 e8c343519..0809ccc43 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 @@ -2,8 +2,10 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus @@ -34,7 +36,7 @@ class MALApi : SyncAPI() { override var name = "MAL" override val idPrefix = "mal" - val key = "1714d6f2f4f7cc19644384f8c4629910" + private val key = BuildConfig.MAL_KEY private val apiUrl = "https://api.myanimelist.net" override val hasOAuth2 = true override val redirectUrlIdentifier: String? = "mallogin" @@ -78,7 +80,7 @@ class MALApi : SyncAPI() { ) ).parsed() return AuthToken( - accessTokenLifetime = unixTime + token.expiresIn.toLong(), + accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(), refreshToken = token.refreshToken, accessToken = token.accessToken ) @@ -98,9 +100,9 @@ class MALApi : SyncAPI() { ) } - override suspend fun search(auth : AuthData?, query: String): List? { + override suspend fun search(auth: AuthData?, query: String): List? { val auth = auth?.token?.accessToken ?: return null - val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" + val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT" val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", @@ -122,7 +124,7 @@ class MALApi : SyncAPI() { Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() override suspend fun updateStatus( - auth : AuthData?, + auth: AuthData?, id: String, newStatus: SyncAPI.AbstractSyncStatus ): Boolean { @@ -225,7 +227,7 @@ class MALApi : SyncAPI() { ) } - override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { + override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? { val auth = auth?.token?.accessToken ?: return null val internalId = id.toIntOrNull() ?: return null val url = @@ -271,7 +273,7 @@ class MALApi : SyncAPI() { } } - override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val auth = auth?.token?.accessToken ?: return null // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get @@ -366,7 +368,7 @@ class MALApi : SyncAPI() { return AuthToken( accessToken = res.accessToken, refreshToken = res.refreshToken, - accessTokenLifetime = unixTime + res.expiresIn.toLong() + accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong() ) } @@ -477,7 +479,7 @@ class MALApi : SyncAPI() { @JsonProperty("start_time") val startTime: String? ) - override suspend fun library(auth : AuthData?): LibraryMetadata? { + override suspend fun library(auth: AuthData?): LibraryMetadata? { val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> @@ -505,7 +507,7 @@ class MALApi : SyncAPI() { ) } - private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { + private suspend fun getMalAnimeListSmart(auth: AuthData): Array? { return if (requireLibraryRefresh) { val list = getMalAnimeList(auth.token) setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) 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 02f828a22..15ef6bfab 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,9 +2,10 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AuthData @@ -13,9 +14,12 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag class OpenSubtitlesApi : SubtitleAPI() { override val name = "OpenSubtitles" @@ -41,17 +45,17 @@ class OpenSubtitlesApi : SubtitleAPI() { } private fun canDoRequest(): Boolean { - return unixTimeMs > currentCoolDown + return unixTimeMS > currentCoolDown } private fun throwIfCantDoRequest() { if (!canDoRequest()) { - throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s") + throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s") } } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMs + COOLDOWN_DURATION + currentCoolDown = unixTimeMS + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } @@ -87,29 +91,11 @@ class OpenSubtitlesApi : SubtitleAPI() { accessToken = response.token ?: throw ErrorLoadingException("Invalid password or username"), /// JWT token is valid 24 hours after successfully authentication of user - accessTokenLifetime = unixTime + 60 * 60 * 24, + accessTokenLifetime = APIHolder.unixTime + 60 * 60 * 24, payload = form.toJson() ) } - /** - * Some languages do not use the normal country codes on OpenSubtitles - * */ - private val languageExceptions = mapOf( -// "pt" to "pt-PT", -// "pt" to "pt-BR" - ) - - 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? { - return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language - } - - /** * Fetch subtitles using token authenticated on previous method (see authorize). * Returns list of Subtitles which user can select to download (see load). @@ -119,7 +105,7 @@ class OpenSubtitlesApi : SubtitleAPI() { query: AbstractSubtitleEntities.SubtitleSearch ): List? { throwIfCantDoRequest() - val fixedLang = fixLanguage(query.lang) + val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query @@ -132,8 +118,8 @@ class OpenSubtitlesApi : SubtitleAPI() { 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=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" } val req = app.get( @@ -142,6 +128,7 @@ class OpenSubtitlesApi : SubtitleAPI() { Pair("Content-Type", "application/json") ) + headers, ) + Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}") Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { if (req.code == 429) @@ -162,7 +149,7 @@ class OpenSubtitlesApi : SubtitleAPI() { //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 langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year @@ -176,7 +163,7 @@ class OpenSubtitlesApi : SubtitleAPI() { AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = name, - lang = lang, + lang = langTagIETF, data = resultData, type = type, source = this.name, 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 index 9518f5a20..075c08bb8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -4,19 +4,19 @@ import androidx.annotation.StringRes import androidx.core.net.toUri 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.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score 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.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING @@ -30,6 +30,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.txt import java.math.BigInteger @@ -77,15 +78,15 @@ class SimklApi : SyncAPI() { private class SimklCacheWrapper( @JsonProperty("obj") val obj: T?, @JsonProperty("validUntil") val validUntil: Long, - @JsonProperty("cacheTime") val cacheTime: Long = unixTime, + @JsonProperty("cacheTime") val cacheTime: Long = APIHolder.unixTime, ) { /** Returns true if cache is newer than cacheDays */ fun isFresh(): Boolean { - return validUntil > unixTime + return validUntil > APIHolder.unixTime } fun remainingTime(): Duration { - val unixTime = unixTime + val unixTime = APIHolder.unixTime return if (validUntil > unixTime) { (validUntil - unixTime).toDuration(DurationUnit.SECONDS) } else { @@ -96,7 +97,7 @@ class SimklApi : SyncAPI() { fun cleanOldCache() { getKeys(SIMKL_CACHE_KEY)?.forEach { - val isOld = AcraApplication.getKey>(it)?.isFresh() == false + val isOld = CloudStreamApp.getKey>(it)?.isFresh() == false if (isOld) { removeKey(it) } @@ -109,7 +110,7 @@ class SimklApi : SyncAPI() { SIMKL_CACHE_KEY, path, // Storing as plain sting is required to make generics work. - SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson() + SimklCacheWrapper(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson() ) } @@ -117,13 +118,8 @@ class SimklApi : SyncAPI() { * 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) + tryParseJson>(it) } return if (cache?.isFresh() == true) { @@ -423,7 +419,7 @@ class SimklApi : SyncAPI() { } suspend fun execute(): Boolean { - val time = getDateTime(unixTime) + val time = getDateTime(APIHolder.unixTime) val headers = this.headers ?: emptyMap() return if (this.status == SimklListStatusType.None.value) { app.post( @@ -573,7 +569,7 @@ class SimklApi : SyncAPI() { @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("rating") val rating: Int, - @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime) + @JsonProperty("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -582,7 +578,7 @@ class SimklApi : SyncAPI() { @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("to") val to: String, - @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) + @JsonProperty("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -867,7 +863,7 @@ class SimklApi : SyncAPI() { newStatus: AbstractSyncStatus ): Boolean { val parsedId = readIdFromString(id) - lastScoreTime = unixTime + lastScoreTime = APIHolder.unixTime val simklStatus = newStatus as? SimklSyncStatus val builder = SimklScoreBuilder.Builder() @@ -916,7 +912,7 @@ class SimklApi : SyncAPI() { override suspend fun search(auth: AuthData?, query: String): List? { return app.get( - "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } } 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 index df635c13c..19122768e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt @@ -29,7 +29,7 @@ class SubSourceApi : SubtitleAPI() { //Only supports Imdb Id search for now if (query.imdbId == null) return null - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!) + val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie val searchRes = app.post( 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 index efe96371f..19bd3b1a7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt @@ -1,9 +1,8 @@ package com.lagradost.cloudstream3.syncproviders.providers import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.syncproviders.AuthData @@ -12,6 +11,9 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable class SubDlApi : SubtitleAPI() { override val name = "SubDL" @@ -24,7 +26,7 @@ class SubDlApi : SubtitleAPI() { override val createAccountUrl = "https://subdl.com/panel/register" companion object { - const val APIURL = "https://apiold.subdl.com" + const val APIURL = "https://api.subdl.com" const val APIENDPOINT = "$APIURL/api/v1/subtitles" const val DOWNLOADENDPOINT = "https://dl.subdl.com" } @@ -65,6 +67,7 @@ class SubDlApi : SubtitleAPI() { val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 + val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang val idQuery = when { query.imdbId != null -> "&imdb_id=${query.imdbId}" @@ -78,8 +81,8 @@ class SubDlApi : SubtitleAPI() { val searchQueryUrl = when (idQuery) { //Use imdb/tmdb id to search if its valid - null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery" - else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" + else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" } val req = app.get( @@ -91,7 +94,9 @@ class SubDlApi : SubtitleAPI() { return req.parsedSafe()?.subtitles?.map { subtitle -> - val lang = subtitle.lang.replaceFirstChar { it.uppercase() } + val langTagIETF = + langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: + subtitle.lang val resEpNum = subtitle.episode ?: query.epNumber val resSeasonNum = subtitle.season ?: query.seasonNumber val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie @@ -99,7 +104,7 @@ class SubDlApi : SubtitleAPI() { AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = subtitle.releaseName, - lang = lang, + lang = langTagIETF, data = "${DOWNLOADENDPOINT}${subtitle.url}", type = type, source = this.name, @@ -119,68 +124,146 @@ class SubDlApi : SubtitleAPI() { } } + @Serializable 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, + @JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String, + @JsonProperty("pass") @SerialName("pass") var pass: String, + @JsonProperty("name") @SerialName("name") var name: String? = null, + @JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null, + @JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null, ) + @Serializable data class OAuthTokenResponse( - @JsonProperty("token") val token: String, - @JsonProperty("userData") val userData: UserData? = null, - @JsonProperty("status") val status: Boolean? = null, - @JsonProperty("message") val message: String? = null, + @JsonProperty("token") @SerialName("token") val token: String, + @JsonProperty("userData") @SerialName("userData") val userData: UserData? = null, + @JsonProperty("status") @SerialName("status") val status: Boolean? = null, + @JsonProperty("message") @SerialName("message") val message: String? = null, ) + @Serializable 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, + @JsonProperty("email") @SerialName("email") val email: String, + @JsonProperty("name") @SerialName("name") val name: String, + @JsonProperty("country") @SerialName("country") val country: String, + @JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String, + @JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean, + @JsonProperty("username") @SerialName("username") val username: String? = null, + @JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String, ) + @Serializable data class ApiKeyResponse( - @JsonProperty("ok") val ok: Boolean? = false, - @JsonProperty("api_key") val apiKey: String, - @JsonProperty("usage") val usage: Usage? = null, + @JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false, + @JsonProperty("api_key") @SerialName("api_key") val apiKey: String, + @JsonProperty("usage") @SerialName("usage") val usage: Usage? = null, ) + @Serializable data class Usage( - @JsonProperty("total") val total: Long? = 0, - @JsonProperty("today") val today: Long? = 0, + @JsonProperty("total") @SerialName("total") val total: Long? = 0, + @JsonProperty("today") @SerialName("today") val today: Long? = 0, ) + @Serializable data class ApiResponse( - @JsonProperty("status") val status: Boolean? = null, - @JsonProperty("results") val results: List? = null, - @JsonProperty("subtitles") val subtitles: List? = null, + @JsonProperty("status") @SerialName("status") val status: Boolean? = null, + @JsonProperty("results") @SerialName("results") val results: List? = null, + @JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List? = null, ) + @Serializable 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, + @JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null, + @JsonProperty("type") @SerialName("type") val type: String? = null, + @JsonProperty("name") @SerialName("name") val name: String? = null, + @JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null, + @JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null, + @JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null, + @JsonProperty("year") @SerialName("year") val year: Int? = null, ) + @Serializable 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, + @JsonProperty("release_name") @SerialName("release_name") val releaseName: String, + @JsonProperty("name") @SerialName("name") val name: String, + @JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code + @JsonProperty("author") @SerialName("author") val author: String? = null, + @JsonProperty("url") @SerialName("url") val url: String? = null, + @JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null, + @JsonProperty("season") @SerialName("season") val season: Int? = null, + @JsonProperty("episode") @SerialName("episode") val episode: Int? = null, + @JsonProperty("language") @SerialName("language") val language: String? = null, // full language name + @JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null, + ) + + // https://subdl.com/api-files/language_list.json + // most of it is IETF BPC 47 conformant tag + // but there are some exceptions + private val langTagIETF2subdl = mapOf( + "en-bg" to "BG_EN", // "Bulgarian_English" + "en-de" to "EN_DE", // "English_German" + "en-hu" to "HU_EN", // "Hungarian_English" + "en-nl" to "NL_EN", // "Dutch_English" + "pt-br" to "BR_PT", // "Brazillian Portuguese" + "zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?) + // "ar" to "AR", // "Arabic" + // "az" to "AZ", // "Azerbaijani" + // "be" to "BE", // "Belarusian" + // "bg" to "BG", // "Bulgarian" + // "bn" to "BN", // "Bengali" + // "bs" to "BS", // "Bosnian" + // "ca" to "CA", // "Catalan" + // "cs" to "CS", // "Czech" + // "da" to "DA", // "Danish" + // "de" to "DE", // "German" + // "el" to "EL", // "Greek" + // "en" to "EN", // "English" + // "eo" to "EO", // "Esperanto" + // "es" to "ES", // "Spanish" + // "et" to "ET", // "Estonian" + // "fa" to "FA", // "Farsi_Persian" + // "fi" to "FI", // "Finnish" + // "fr" to "FR", // "French" + // "he" to "HE", // "Hebrew" + // "hi" to "HI", // "Hindi" + // "hr" to "HR", // "Croatian" + // "hu" to "HU", // "Hungarian" + // "id" to "ID", // "Indonesian" + // "is" to "IS", // "Icelandic" + // "it" to "IT", // "Italian" + // "ja" to "JA", // "Japanese" + // "ka" to "KA", // "Georgian" + // "kl" to "KL", // "Greenlandic" + // "ko" to "KO", // "Korean" + // "ku" to "KU", // "Kurdish" + // "lt" to "LT", // "Lithuanian" + // "lv" to "LV", // "Latvian" + // "mk" to "MK", // "Macedonian" + // "ml" to "ML", // "Malayalam" + // "mni" to "MNI", // "Manipuri" + // "ms" to "MS", // "Malay" + // "my" to "MY", // "Burmese" + // "nl" to "NL", // "Dutch" + // "no" to "NO", // "Norwegian" + // "pl" to "PL", // "Polish" + // "pt" to "PT", // "Portuguese" + // "ro" to "RO", // "Romanian" + // "ru" to "RU", // "Russian" + // "si" to "SI", // "Sinhala" + // "sk" to "SK", // "Slovak" + // "sl" to "SL", // "Slovenian" + // "sq" to "SQ", // "Albanian" + // "sr" to "SR", // "Serbian" + // "sv" to "SV", // "Swedish" + // "ta" to "TA", // "Tamil" + // "te" to "TE", // "Telugu" + // "th" to "TH", // "Thai" + // "tl" to "TL", // "Tagalog" + // "tr" to "TR", // "Turkish" + // "uk" to "UK", // "Ukranian" + // "ur" to "UR", // "Urdu" + // "vi" to "VI", // "Vietnamese" + // "zh" to "ZH", // "Chinese BG code" ) } 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 492efacec..8ec082520 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -9,14 +9,15 @@ 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.SearchResponseList 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.newSearchResponseList +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async @@ -28,7 +29,7 @@ class APIRepository(val api: MainAPI) { // 2 minute timeout to prevent bad extensions/extractors from hogging the resources // No real provider should take longer, so we hard kill them. private const val DEFAULT_TIMEOUT = 120_000L - private const val MAX_TIMEOUT = 4*DEFAULT_TIMEOUT + private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT private const val MIN_TIMEOUT = 5_000L var dubStatusActive = HashSet() @@ -54,20 +55,18 @@ class APIRepository(val api: MainAPI) { val hash: Pair ) - private val cache = threadSafeListOf() + private val cache = atomicListOf() private var cacheIndex: Int = 0 const val CACHE_SIZE = 20 - fun getTimeout(desired : Long?) : Long { - return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) + fun getTimeout(desired: Long?): Long { + return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) } } private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { - synchronized(cache) { - cache.clear() - } + cache.clear() } } @@ -90,21 +89,25 @@ class APIRepository(val api: MainAPI) { val fixedUrl = api.fixUrl(url) val lookingForHash = Pair(api.name, fixedUrl) - synchronized(cache) { + val cached = cache.withLock { + var found: LoadResponse? = null for (item in cache) { // 10 min save if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { - return@withTimeout item.response + found = item.response + break } } + found } + if (cached != null) return@withTimeout cached api.load(fixedUrl)?.also { response -> // Remove all blank tags as early as possible response.tags = response.tags?.filter { it.isNotBlank() } val add = SavedLoadResponse(unixTime, response, lookingForHash) - synchronized(cache) { + cache.withLock { if (cache.size > CACHE_SIZE) { cache[cacheIndex] = add // rolling cache cacheIndex = (cacheIndex + 1) % CACHE_SIZE @@ -117,27 +120,29 @@ class APIRepository(val api: MainAPI) { } } - suspend fun search(query: String): Resource> { + suspend fun search(query: String, page: Int): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { withTimeout(getTimeout(api.searchTimeoutMs)) { - (api.search(query) + (api.search(query, page) ?: throw ErrorLoadingException()) - // .filter { typesActive.contains(it.type) } - .toList() + // .filter { typesActive.contains(it.type) } } } } - suspend fun quickSearch(query: String): Resource> { + suspend fun quickSearch(query: String): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { withTimeout(getTimeout(api.quickSearchTimeoutMs)) { - api.quickSearch(query) ?: throw ErrorLoadingException() + newSearchResponseList( + api.quickSearch(query) ?: throw ErrorLoadingException(), + false + ) } } } @@ -212,4 +217,4 @@ class APIRepository(val api: MainAPI) { return false } } -} \ 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 index e930961c5..4ebb7564c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -1,34 +1,55 @@ package com.lagradost.cloudstream3.ui +import android.content.Context import android.view.View import android.view.ViewGroup +import android.widget.ImageView 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 coil3.dispose +import java.util.WeakHashMap 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 } +abstract class NoStateAdapter( + diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() +) : BaseAdapter(0, diffCallback) -// 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>() +/** Creates a new shared pool, using the supplied lambda as a constructor. + * + * The reason for this complicated structure is that a pool should not be shared between contexts + * as it makes coil fuck up, and theming. + * */ +fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair, RecyclerView.RecycledViewPool.() -> Unit> = + WeakHashMap() to lambda + +/** Sets the shared pool of the recyclerview */ +fun RecyclerView.setRecycledViewPool(pool: Pair, RecyclerView.RecycledViewPool.() -> Unit>) { + val ctx = context ?: return + synchronized(pool.first) { + this.setRecycledViewPool(pool.first.getOrPut(ctx) { + RecyclerView.RecycledViewPool().apply(pool.second) + }) + } } -abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter(fragment, 0) +/** Clears the shared pool of views */ +fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { + synchronized(this.first) { + for (pool in this.first.values) { + pool?.clear() + } + } +} /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. @@ -49,13 +70,14 @@ abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter 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 + val immutableCurrentList: List get() = mDiffer.currentList + fun getItem(position: Int): T { return mDiffer.currentList[position] } @@ -85,9 +107,33 @@ abstract class BaseAdapter< AsyncDifferConfig.Builder(diffCallback).build() ) - open fun submitList(list: List?) { + /** + * Instantly submits a **new and fresh** list. This means that no changes like moves are done as + * we assume the new list is not the same thing as the old list, nothing is shared. + * + * The views are rendered instantly as a result, so no fade/pop-ins or similar. + * + * Use `submitList` for general use, as that can reuse old views. + * */ + open fun submitIncomparableList(list: List?, commitCallback : Runnable? = null) { + // This leverages a quirk in the submitList function that has a fast case for null arrays + // What this implies is that as long as we do a double submit we can ensure no pop-ins, + // as the changes are the entire list instead of calculating deltas + submitList(null) + submitList(list, commitCallback) + } + + /** + * @param commitCallback Optional runnable that is executed when the List is committed, if it is committed. + * This is needed for some tasks as submitList will use a background thread for diff + * */ + open fun submitList(list: Collection?, commitCallback : Runnable? = null) { // deep copy at least the top list, because otherwise adapter can go crazy - mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) }) + if (list.isNullOrEmpty()) { + mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList() + } else { + mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback) + } } override fun getItemCount(): Int { @@ -101,16 +147,25 @@ abstract class BaseAdapter< open fun onBindFooter(holder: ViewHolderState) = Unit open fun onBindHeader(holder: ViewHolderState) = Unit open fun onCreateContent(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomContent( + parent: ViewGroup, + viewType: Int + ) = onCreateContent(parent) + open fun onCreateFooter(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomFooter( + parent: ViewGroup, + viewType: Int + ) = onCreateFooter(parent) + open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomHeader( + parent: ViewGroup, + viewType: Int + ) = onCreateHeader(parent) - override fun onViewAttachedToWindow(holder: ViewHolderState) { - holder.onViewAttachedToWindow() - } - - override fun onViewDetachedFromWindow(holder: ViewHolderState) { - holder.onViewDetachedFromWindow() - } + override fun onViewAttachedToWindow(holder: ViewHolderState) {} + override fun onViewDetachedFromWindow(holder: ViewHolderState) {} @Suppress("UNCHECKED_CAST") fun save(recyclerView: RecyclerView) { @@ -121,21 +176,20 @@ abstract class BaseAdapter< } } - fun clear() { - stateViewModel.layoutManagerStates[id]?.clear() + fun clearState() { + layoutManagerStates[id]?.clear() } @Suppress("UNCHECKED_CAST") private fun getState(holder: ViewHolderState): S? = - stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S + 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() + if (id == 0) return + if (!layoutManagerStates.contains(id)) { + layoutManagerStates[id] = HashMap() } - stateViewModel.layoutManagerStates[id]?.let { map -> + layoutManagerStates[id]?.let { map -> map[holder.absoluteAdapterPosition] = holder.save() } } @@ -158,30 +212,40 @@ abstract class BaseAdapter< super.onDetachedFromRecyclerView(recyclerView) } + open fun customContentViewType(item: T): Int = 0 + open fun customFooterViewType(): Int = 0 + open fun customHeaderViewType(): Int = 0 + final override fun getItemViewType(position: Int): Int { if (position < headers) { - return HEADER + return HEADER or customHeaderViewType() } - if (position - headers >= mDiffer.currentList.size) { - return FOOTER + val realPosition = position - headers + if (realPosition >= mDiffer.currentList.size) { + return FOOTER or customFooterViewType() } - - return CONTENT + return CONTENT or customContentViewType(getItem(realPosition)) } - private val stateViewModel: StateViewModel by fragment.viewModels() - final override fun onViewRecycled(holder: ViewHolderState) { setState(holder) - holder.onViewRecycled() + onClearView(holder) super.onViewRecycled(holder) } + /** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data. + * + * If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources. + * + * Use this with `clearImage` + * */ + open fun onClearView(holder: ViewHolderState) {} + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState { - return when (viewType) { - CONTENT -> onCreateContent(parent) - HEADER -> onCreateHeader(parent) - FOOTER -> onCreateFooter(parent) + return when (viewType and TYPE_MASK) { + CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) + HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) + FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) else -> throw NotImplementedError() } } @@ -196,7 +260,7 @@ abstract class BaseAdapter< super.onBindViewHolder(holder, position, payloads) return } - when (getItemViewType(position)) { + when (getItemViewType(position) and TYPE_MASK) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -214,7 +278,7 @@ abstract class BaseAdapter< } final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { - when (getItemViewType(position)) { + when (getItemViewType(position) and TYPE_MASK) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -236,9 +300,20 @@ abstract class BaseAdapter< } companion object { - private const val HEADER: Int = 1 - private const val FOOTER: Int = 2 - private const val CONTENT: Int = 0 + val layoutManagerStates = hashMapOf>() + fun clearImage(image: ImageView?) { + image?.dispose() + } + + // Use the lowermost MASK_SIZE bits for the custom content, + // use the uppermost 32 - MASK_SIZE to the type + private const val MASK_SIZE = 28 + private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1 + private const val TYPE_MASK = CUSTOM_MASK.inv() + const val HEADER: Int = 3 shl MASK_SIZE + const val FOOTER: Int = 2 shl MASK_SIZE + /** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */ + const val CONTENT: Int = 1 shl MASK_SIZE } } @@ -248,5 +323,5 @@ class BaseDiffCallback( ) : 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() + 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/BaseFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt new file mode 100644 index 000000000..72955e7cf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt @@ -0,0 +1,278 @@ +package com.lagradost.cloudstream3.ui + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.LayoutRes +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceFragmentCompat +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.utils.txt + +/** + * A base Fragment class that simplifies ViewBinding usage and handles view inflation safely. + * + * This class allows two modes of creating ViewBinding: + * 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes. + * 2. Bind: Using `bind()` on an existing root view. + * + * It also provides hooks for: + * - Safe initialization of the binding (`onBindingCreated`) + * - Automatic padding adjustment for system bars (`fixPadding`) + * - Optional layout resource selection via `pickLayout()` + * + * @param T The type of ViewBinding for this Fragment. + * @param bindingCreator The strategy used to create the binding instance. + */ +private interface BaseFragmentHelper { + val bindingCreator: BaseFragment.BindingCreator + + var _binding: T? + val binding: T? get() = _binding + + fun createBinding( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val layoutId = pickLayout() + val root: View? = layoutId?.let { inflater.inflate(it, container, false) } + _binding = try { + when (val creator = bindingCreator) { + is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false) + is BaseFragment.BindingCreator.Bind -> { + if (root != null) creator.fn(root) + else throw IllegalStateException("Root view is null for bind()") + } + } + } catch (t: Throwable) { + showToast( + txt(R.string.unable_to_inflate, t.message ?: ""), + Toast.LENGTH_LONG + ) + logError(t) + null + } + + return _binding?.root ?: root + } + + /** + * Called after the fragment's view has been created. + * + * This method is `final` to ensure that the binding is properly initialized and + * system bar padding adjustments are applied before any subclass logic runs. + * Subclasses should use [onBindingCreated] instead of overriding this method directly. + */ + fun onViewReady(view: View, savedInstanceState: Bundle?) { + fixLayout(view) + binding?.let { onBindingCreated(it, savedInstanceState) } + } + + /** + * Called when the binding is safely created and view is ready. + * Can be overridden to provide fragment-specific initialization. + * + * @param binding The safely created ViewBinding. + * @param savedInstanceState Saved state bundle or null. + */ + fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + onBindingCreated(binding) + } + + /** + * Called when the binding is safely created and view is ready. + * Overload without savedInstanceState for convenience. + * + * @param binding The safely created ViewBinding. + */ + fun onBindingCreated(binding: T) {} + + /** + * Pick a layout resource ID for the fragment. + * + * Return `null` by default. Override to provide a layout resource when using + * `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`. + * + * @return Layout resource ID or null. + */ + @LayoutRes + fun pickLayout(): Int? = null + + /** + * Ensures the layout of the root view is correctly adjusted for the current configuration. + * + * This may include applying padding for system bars, adjusting insets, or performing other + * layout updates. `fixLayout` should remain idempotent, as it can be called multiple + * times on the same view, such as during configuration changes (e.g. device rotation) or when + * the view is recreated. + * + * @param view The root view to adjust. + */ + fun fixLayout(view: View) +} + +abstract class BaseFragment( + override val bindingCreator: BindingCreator +) : Fragment(), BaseFragmentHelper { + override var _binding: T? = null + + /** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */ + fun dispatchBackPressed() { + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(5) + } catch (t: Throwable) { + logError(t) + } + } + + /** Recursive back press when available */ + private fun delayedDispatchBackPressed(remaining: Int) { + if (remaining <= 0) return + binding?.root?.postDelayed({ + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(remaining - 1) + } catch (t: Throwable) { + logError(t) + } + }, 200) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** + * Called when the device configuration changes (e.g., orientation). + * Re-applies system bar padding fixes to the root view to ensure it + * readjusts for orientation changes. + */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /** + * Sealed class representing the two strategies for creating a ViewBinding instance. + */ + sealed class BindingCreator { + + /** + * Use the standard inflate() method for creating the binding. + * + * @param fn Lambda that inflates the binding. + */ + class Inflate( + val fn: (LayoutInflater, ViewGroup?, Boolean) -> T + ) : BindingCreator() + + /** + * Use bind() on an existing root view to create the binding. This should + * be used if you are differing per device layouts, such as different + * layouts for TV and Phone. + * + * @param fn Lambda that binds the root view. + */ + class Bind( + val fn: (View) -> T + ) : BindingCreator() + } +} + +abstract class BaseDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : DialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BaseBottomSheetDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : BottomSheetDialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setSystemBarsPadding() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + setSystemBarsPadding() + } +} 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 e66b57ab1..2aadfb13c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -12,9 +12,6 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import androidx.appcompat.app.AlertDialog -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions @@ -105,9 +102,6 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { - private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() - init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { @@ -245,7 +239,12 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi .setPlayPosition(startAt) .setAutoplay(true) .build() - awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) { + awaitLinks( + remoteMediaClient?.load( + mediaItem, + mediaLoadOptions + ) + ) { loadMirror(index + 1) } } @@ -299,7 +298,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) + DataStoreHelper.setViewPosAndResume( + epData.id, + currentPosition, + currentDuration, + epData, + meta.episodes.getOrNull(index + 1) + ) } catch (t: Throwable) { logError(t) } @@ -315,7 +320,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val isSuccessful = safeApiCall { generator.generateLinks( clearCache = false, - allowedTypes = LOADTYPE_CHROMECAST, + sourceTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) @@ -323,7 +328,9 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi }, subtitleCallback = { currentSubs.add(it) }, - isCasting = true) + offset = 0, + isCasting = true + ) } val sortedLinks = sortUrls(currentLinks) @@ -436,4 +443,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 78ad2a6bf..302358538 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View +import androidx.core.content.withStyledAttributes import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -154,10 +155,9 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att init { if (attrs != null) { - val attrsArray = intArrayOf(android.R.attr.columnWidth) - val array = context.obtainStyledAttributes(attrs, attrsArray) - columnWidth = array.getDimensionPixelSize(0, -1) - array.recycle() + context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { + columnWidth = getDimensionPixelSize(0, -1) + } } layoutManager = manager diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt index bf7f6b8fc..9be862077 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt @@ -4,17 +4,13 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater import android.view.MotionEvent import android.view.View -import android.view.ViewGroup import android.view.animation.AccelerateInterpolator import android.view.animation.LinearInterpolator import android.widget.FrameLayout import android.widget.ImageView import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding @@ -26,10 +22,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.random.Random -class EasterEggMonkeFragment : Fragment() { - - private var _binding: FragmentEasterEggMonkeBinding? = null - private val binding get() = _binding!! +class EasterEggMonkeFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate) +) { // planet of monks private val monkeys: List = listOf( @@ -51,27 +46,20 @@ class EasterEggMonkeFragment : Fragment() { private val activeMonkeys = mutableListOf() private var spawningJob: Job? = null - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - _binding = FragmentEasterEggMonkeBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) = Unit + override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) { activity?.hideSystemUI() spawningJob = lifecycleScope.launch { delay(1000) while (isActive) { - spawnMonkey() + spawnMonkey(binding) delay(500) } } } - private fun spawnMonkey() { + private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) { val newMonkey = ImageView(context ?: return).apply { setImageResource(monkeys.random()) isVisible = true @@ -102,12 +90,12 @@ class EasterEggMonkeFragment : Fragment() { } @SuppressLint("ClickableViewAccessibility") - newMonkey.setOnTouchListener { view, event -> handleTouch(view, event) } + newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) } - startFloatingAnimation(newMonkey) + startFloatingAnimation(newMonkey, binding) } - private fun startFloatingAnimation(monkey: ImageView) { + private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { val floatUpAnimator = ObjectAnimator.ofFloat( monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat() ).apply { @@ -117,11 +105,8 @@ class EasterEggMonkeFragment : Fragment() { floatUpAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - // necessary check because binding becomes null but monkes are still moving until onDestroy() - if (_binding != null) { - binding.frame.removeView(monkey) - activeMonkeys.remove(monkey) - } + binding.frame.removeView(monkey) + activeMonkeys.remove(monkey) } }) @@ -129,7 +114,11 @@ class EasterEggMonkeFragment : Fragment() { monkey.tag = floatUpAnimator } - private fun handleTouch(view: View, event: MotionEvent): Boolean { + private fun handleTouch( + view: View, + event: MotionEvent, + binding: FragmentEasterEggMonkeBinding + ): Boolean { val monkey = view as ImageView when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -143,17 +132,17 @@ class EasterEggMonkeFragment : Fragment() { monkey.y = event.rawY - monkey.height / 2 // Check if monkey touches the screen edge - if (isTouchingEdge(monkey)) { - removeMonkey(monkey) + if (isTouchingEdge(monkey, binding)) { + removeMonkey(monkey, binding) } return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (isTouchingEdge(monkey)) { - removeMonkey(monkey) + if (isTouchingEdge(monkey, binding)) { + removeMonkey(monkey, binding) } else { - startFloatingAnimation(monkey) + startFloatingAnimation(monkey, binding) } return true } @@ -161,12 +150,12 @@ class EasterEggMonkeFragment : Fragment() { return false } - private fun isTouchingEdge(monkey: ImageView): Boolean { + private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean { return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width || monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height } - private fun removeMonkey(monkey: ImageView) { + private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { // Fade out and remove the monkey ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply { duration = 300 @@ -184,6 +173,5 @@ class EasterEggMonkeFragment : Fragment() { super.onDestroyView() activity?.showSystemUI() spawningJob?.cancel() - _binding = null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt deleted file mode 100644 index 40c03012a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.ui - -import android.graphics.Canvas -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView - -class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() { - override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { - super.onDraw(c, parent, state) - customView.layout(parent.left, 0, parent.right, customView.measuredHeight) - for (i in 0 until parent.childCount) { - val view = parent.getChildAt(i) - if (parent.getChildAdapterPosition(view) == 0) { - c.save() - val height = customView.measuredHeight - val top = view.top - height - c.translate(0f, top.toFloat()) - customView.draw(c) - c.restore() - break - } - } - } - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State - ) { - if (parent.getChildAdapterPosition(view) == 0) { - customView.measure( - View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST), - View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST) - ) - outRect.set(0, customView.measuredHeight, 0, 0) - } else { - outRect.setEmpty() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt index b6326eb36..bd8541e6b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout +import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx -import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - super.onInflate(context, attributeSet, bundle) - - // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? if (currentColor == 0) { - WeakReference( - context.obtainStyledAttributes( - attributeSet, - R.styleable.CustomCast - ) - ).apply { - if (get() - ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true - ) { - currentColor = - get() - ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { + if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { + currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) } - get()?.recycle() - }.clear() + } } + + super.onInflate(context, attributeSet, bundle) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 5e2b97e57..0d951bf6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,17 +1,12 @@ package com.lagradost.cloudstream3.ui import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup 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.USER_AGENT @@ -19,19 +14,18 @@ import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository +class WebviewFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate) +) { -class WebviewFragment : Fragment() { + override fun fixLayout(view: View) = Unit - var binding: FragmentWebviewBinding? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentWebviewBinding) { val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } - binding?.webView?.webViewClient = object : WebViewClient() { - @OptIn(UnstableApi::class) + binding.webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -46,28 +40,17 @@ class WebviewFragment : Fragment() { return super.shouldOverrideUrlLoading(view, request) } } - binding?.webView?.apply { + + binding.webView.apply { WebViewResolver.webViewUserAgent = settings.userAgentString addJavascriptInterface(RepoApi(activity), "RepoApi") settings.javaScriptEnabled = true settings.userAgentString = USER_AGENT settings.domStorageEnabled = true -// WebView.setWebContentsDebuggingEnabled(true) loadUrl(url) } - - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentWebviewBinding.inflate(inflater, container, false) - binding = localBinding - // Inflate the layout for this fragment - return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false) } companion object { @@ -84,4 +67,4 @@ class WebviewFragment : Fragment() { activity?.loadRepository(repoUrl) } } -} \ No newline at end of file +} 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 index 1e7e0f112..92d33d0f3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt @@ -1,16 +1,17 @@ package com.lagradost.cloudstream3.ui.account +import android.os.Build 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 coil3.transform.RoundedCornersTransformation 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.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -19,137 +20,174 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ImageLoader.loadImage 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() { +) : NoStateAdapter() { 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 + override val footers: Int = 1 + var viewType = VIEW_TYPE_SELECT_ACCOUNT - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + override fun customContentViewType(item: DataStoreHelper.Account): Int { + return viewType + } - val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex + override fun onBindContent( + holder: ViewHolderState, + item: DataStoreHelper.Account, + position: Int + ) { + when (val binding = holder.view) { + is AccountListItemBinding -> binding.apply { + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode - accountName.text = account.name - accountImage.loadImage(account.image) - lockIcon.isVisible = account.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount + val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() - } + accountName.text = item.name + accountImage.loadImage(item.image) + lockIcon.isVisible = item.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount - 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 - } + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() } - 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.loadImage(account.image) { - RoundedCornersTransformation(10f) - } - 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() - } - + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { root.foreground = ContextCompat.getDrawable( root.context, R.drawable.outline_drawable ) } - - root.setOnClickListener { + } else { + root.setOnLongClickListener { showAccountEditDialog( context = root.context, - account = account, + account = item, isNewAccount = false, - accountEditCallback = { account -> accountEditCallback.invoke(account) }, - accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } + accountEditCallback = { account -> + accountEditCallback.invoke( + account + ) + }, + accountDeleteCallback = { account -> + accountDeleteCallback.invoke( + account + ) + } + ) + + true + } + } + + root.setOnClickListener { + accountSelectCallback.invoke(item) + } + } + + is AccountListItemEditBinding -> binding.apply { + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = item.name + accountImage.loadImage(item.image) { + RoundedCornersTransformation(10f) + } + lockIcon.isVisible = item.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable ) } } - 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 = {} - ) - } + root.setOnClickListener { + showAccountEditDialog( + context = root.context, + account = item, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> + accountDeleteCallback.invoke( + account + ) + } + ) } } } } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder = - AccountViewHolder( - binding = when (viewType) { + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? AccountListItemAddBinding ?: return + binding.apply { + root.setOnClickListener { + val accounts = this@AccountAdapter.immutableCurrentList + + 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 onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + AccountListItemAddBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + when (viewType) { VIEW_TYPE_SELECT_ACCOUNT -> { AccountListItemBinding.inflate( LayoutInflater.from(parent.context), @@ -157,13 +195,7 @@ class AccountAdapter( false ) } - VIEW_TYPE_ADD_ACCOUNT -> { - AccountListItemAddBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - } + VIEW_TYPE_EDIT_ACCOUNT -> { AccountListItemEditBinding.inflate( LayoutInflater.from(parent.context), @@ -171,28 +203,9 @@ class AccountAdapter( 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 index 0fd37e245..1d6b41e5b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -21,7 +21,7 @@ import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -392,7 +392,6 @@ object AccountHelper { activity.observe(viewModel.accounts) { liveAccounts -> recyclerView.adapter = AccountAdapter( - liveAccounts, accountSelectCallback = { account -> viewModel.handleAccountSelect(account, activity) builder.dismissSafe() @@ -400,7 +399,9 @@ object AccountHelper { accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } - ) + ).apply { + submitList(liveAccounts) + } activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> // Scroll to current account (which is focused by default) 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 index a0647219e..ad323c7d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -31,20 +31,22 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAut 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 +import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.openActivity +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat class AccountSelectActivity : FragmentActivity(), BiometricCallback { + companion object { + var hasLoggedIn: Boolean = false + } + val accountViewModel: AccountViewModel by viewModels() @SuppressLint("NotifyDataSetChanged") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - loadThemes(this) - - @Suppress("DEPRECATION") - window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground) // Are we editing and coming from MainActivity? val isEditingFromMainActivity = intent.getBooleanExtra( @@ -52,8 +54,22 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { false ) + // Sometimes we start this activity when we have already logged in + // For example when using cloudstreamsearch:// + // In those cases we want to just go to the main activity instantly + if (hasLoggedIn && !isEditingFromMainActivity) { + navigateToMainActivity() + return + } + + loadThemes(this) + + enableEdgeToEdgeCompat() + setNavigationBarColorCompat(R.attr.primaryBlackBackground) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false + val skipStartup = settingsManager.getBoolean( + getString(R.string.skip_startup_account_select_key), false ) || accounts.count() <= 1 fun askBiometricAuth() { @@ -89,10 +105,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { accountViewModel.handleAccountSelect(currentAccount, this, true) } else { if (accounts.count() > 1) { - showToast(this, getString( - R.string.logged_account, - currentAccount?.name - )) + showToast( + this, getString( + R.string.logged_account, + currentAccount?.name + ) + ) } navigateToMainActivity() @@ -105,12 +123,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { val binding = ActivityAccountSelectBinding.inflate(layoutInflater) setContentView(binding.root) + fixSystemBarsPadding(binding.root, padTop = false) val recyclerView: AutofitRecyclerView = binding.accountRecyclerView observe(accountViewModel.accounts) { liveAccounts -> val adapter = AccountAdapter( - liveAccounts, // Handle the selected account accountSelectCallback = { accountViewModel.handleAccountSelect(it, this) @@ -118,7 +136,6 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, accountEditCallback = { accountViewModel.handleAccountUpdate(it, this) - // We came from MainActivity, return there // and switch to the edited account if (isEditingFromMainActivity) { @@ -126,8 +143,10 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { navigateToMainActivity() } }, - accountDeleteCallback = { accountViewModel.handleAccountDelete(it,this) } - ) + accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } + ).apply { + submitList(liveAccounts) + } recyclerView.adapter = adapter @@ -182,16 +201,19 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { askBiometricAuth() } + @SuppressLint("UnsafeIntentLaunch") private fun navigateToMainActivity() { - openActivity(MainActivity::class.java) + hasLoggedIn = true + // We want to propagate any intent we get here to MainActivity since this is just an intermediary + openActivity(MainActivity::class.java, baseIntent = intent) finish() // Finish the account selection activity } override fun onAuthenticationSuccess() { - Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity") + 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/AccountViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt index af62a2b08..96eaf52a7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt @@ -4,8 +4,8 @@ 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.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog import com.lagradost.cloudstream3.utils.DataStoreHelper 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 index a0e5cabc4..1b48143a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.download +import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.ViewGroup @@ -7,19 +8,18 @@ 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.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState 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.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 @@ -27,6 +27,7 @@ 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_CANCEL_PENDING = 6 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 @@ -34,22 +35,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1 sealed class VisualDownloadCached { abstract val currentBytes: Long abstract val totalBytes: Long - abstract val data: VideoDownloadHelper.DownloadCached + abstract val data: DownloadObjects.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadEpisodeCached, + override val data: DownloadObjects.DownloadEpisodeCached, override var isSelected: Boolean, ) : VisualDownloadCached() data class Header( override val currentBytes: Long, override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadHeaderCached, + override val data: DownloadObjects.DownloadHeaderCached, override var isSelected: Boolean, - val child: VideoDownloadHelper.DownloadEpisodeCached?, + val child: DownloadObjects.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() @@ -57,19 +58,19 @@ sealed class VisualDownloadCached { data class DownloadClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadEpisodeCached + val data: DownloadObjects.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadHeaderCached + val data: DownloadObjects.DownloadHeaderCached ) class DownloadAdapter( private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit, private val onItemSelectionChanged: (Int, Boolean) -> Unit, -) : ListAdapter(DiffCallback()) { +) : NoStateAdapter(DiffCallback()) { private var isMultiDeleteState: Boolean = false @@ -78,112 +79,224 @@ class DownloadAdapter( 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 { - loadImage(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) + private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return + val data = card.data + binding.apply { + episodeHolder.apply { if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) } - } else deleteCheckbox.setOnCheckedChangeListener(null) + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } else { + setOnLongClickListener { + onItemSelectionChanged.invoke(data.id, true) + true + } + } + } - deleteCheckbox.apply { - isVisible = isMultiDeleteState - isChecked = card.isSelected + downloadHeaderPoster.apply { + loadImage(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(binding.root.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) + watchProgressContainer.isVisible = true + downloadHeaderEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val max = (it.duration / 1000).toInt() + val progress = (it.position / 1000).toInt() + + if (max > 0 && progress >= (0.95 * max).toInt()) { + playIcon.setImageResource(R.drawable.ic_baseline_check_24) + isVisible = false + } else { + playIcon.setImageResource(R.drawable.netflix_play) + this.max = max + this.progress = progress + isVisible = true } } } - private fun DownloadHeaderEpisodeBinding.handleChildDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - card.child ?: return - downloadHeaderGotoChild.isVisible = false + downloadButton.resetView() + 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. + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } - val posDur = getViewPos(card.data.id) - downloadHeaderEpisodeProgress.apply { + downloadHeaderInfo.isVisible = true + 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.resetViewData() + watchProgressContainer.isVisible = false + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.isVisible = true + 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(binding: ViewBinding, 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 max = (it.duration / 1000).toInt() + val progress = (it.position / 1000).toInt() + + if (max > 0 && progress >= (0.95 * max).toInt()) { + downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24) + isVisible = false + } else { + downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent) + this.max = max + this.progress = progress + isVisible = true + } } } - val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) + downloadButton.resetView() + 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(card.child.id, 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) } - downloadHeaderInfo.text = formattedSize + 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) } @@ -195,199 +308,105 @@ class DownloadAdapter( ) } - downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + 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) + downloadChildEpisodeText.apply { + text = context.getNameFull(data.name, data.episode, data.season) + isSelected = true // Needed for text repeating } - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onHeaderClickEvent.invoke( - DownloadHeaderClickEvent( - DOWNLOAD_ACTION_GO_TO_CHILD, - card.data - ) - ) - } + downloadChildEpisodeHolder.setOnClickListener { + onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, 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) - } + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) } + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } - else -> { - setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - data - ) + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data ) - } + ) + } + + setOnLongClickListener { + onItemSelectionChanged.invoke(data.id, true) + true } } - - 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 + 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 { + override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { 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) + return ViewHolderState(binding) } - override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { - holder.bind(getItem(position)) - } + override fun onBindContent( + holder: ViewHolderState, + item: VisualDownloadCached, + position: Int + ) { + when (val binding = holder.view) { + is DownloadHeaderEpisodeBinding -> bindHeader( + binding, + item as? VisualDownloadCached.Header + ) - 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") + is DownloadChildEpisodeBinding -> bindChild( + binding, + item as? VisualDownloadCached.Child + ) } } + override fun customContentViewType(item: VisualDownloadCached): Int { + return when (item) { + is VisualDownloadCached.Child -> VIEW_TYPE_CHILD + is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + } + } + + @SuppressLint("NotifyDataSetChanged") 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) - } - } + notifyDataSetChanged() // This is shit, but what can you do? } private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { 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 83e0d0167..dae70ebd7 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 @@ -4,8 +4,8 @@ import android.content.DialogInterface import android.net.Uri import androidx.appcompat.app.AlertDialog import com.google.android.material.snackbar.Snackbar -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -18,8 +18,9 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { @@ -82,7 +83,7 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) + DownloadQueueManager.addToQueue(pkg.toWrapper()) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -95,7 +96,7 @@ object DownloadButtonSetup { DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( + VideoDownloadManager.getDownloadFileInfo( act, click.data.id )?.fileLength @@ -110,24 +111,31 @@ object DownloadButtonSetup { } } + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(id) + } + DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val parent = getKey( + val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) ?.mapNotNull { - getKey(it) + getKey(it) } ?.filter { it.parentId == click.data.parentId } val items = mutableListOf() - val allRelevantEpisodes = episodes?.sortedWith(compareBy { it.season ?: 0 }.thenBy { it.episode }) + val allRelevantEpisodes = + episodes?.sortedWith(compareBy { + it.season ?: 0 + }.thenBy { it.episode }) allRelevantEpisodes?.forEach { - val keyInfo = getKey( + val keyInfo = getKey( VideoDownloadManager.KEY_DOWNLOAD_INFO, it.id.toString() ) ?: return@forEach @@ -141,7 +149,7 @@ object DownloadButtonSetup { uri = Uri.EMPTY, id = it.id, parentId = it.parentId, - name = act.getString(R.string.downloaded_file), + name = it.name ?: act.getString(R.string.downloaded_file), season = it.season, episode = it.episode, headerName = parent.name, @@ -154,7 +162,8 @@ object DownloadButtonSetup { } act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) } + DownloadFileGenerator(items), + items.indexOfFirst { it.id == click.data.id } ) ) } 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 1f5b9e337..d44ea0020 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,32 +1,35 @@ package com.lagradost.cloudstream3.ui.download import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.text.format.Formatter.formatShortFileSize -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup +import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider +import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding +import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.BaseFragment 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.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -class DownloadChildFragment : Fragment() { - private lateinit var downloadsViewModel: DownloadViewModel - private var binding: FragmentChildDownloadsBinding? = null +class DownloadChildFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) +) { + + private val downloadViewModel: DownloadViewModel by activityViewModels() companion object { fun newInstance(headerName: String, folder: String): Bundle { @@ -39,99 +42,104 @@ class DownloadChildFragment : Fragment() { override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") - binding = null + downloadViewModel.clearChildren() 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 fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - 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() - + override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() return } - binding?.downloadChildToolbar?.apply { + context?.let { downloadViewModel.updateChildList(it, folder) } + + binding.downloadChildToolbar.apply { title = name if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() } } setAppBarNoScrollFlagsOnTV() } - binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() + binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() - observe(downloadsViewModel.childCards) { - if (it.isEmpty()) { - activity?.onBackPressedDispatcher?.onBackPressed() - return@observe + observe(downloadViewModel.childCards) { cards -> + when (cards) { + is Resource.Success -> { + if (cards.value.isEmpty()) { + dispatchBackPressed() + } + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) + } + + else -> { + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) + } + } + } + + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) + } + + + binding.apply { + btnDelete.setOnClickListener { view -> + downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) } - (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it) + btnCancel.setOnClickListener { + downloadViewModel.cancelSelection() + } + + btnToggleAll.setOnClickListener { + val allSelected = downloadViewModel.isAllChildrenSelected() + if (allSelected) { + downloadViewModel.clearSelectedItems() + } else { + downloadViewModel.selectAllChildren() + } + } } - observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> - val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + + observeNullable(downloadViewModel.selectedItemIds) { selection -> + val isMultiDeleteState = selection != null + val adapter = binding.downloadChildList.adapter as? DownloadAdapter adapter?.setIsMultiDeleteState(isMultiDeleteState) - binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState - if (!isMultiDeleteState) { + binding.downloadDeleteAppbar.isVisible = isMultiDeleteState + binding.downloadChildToolbar.isGone = isMultiDeleteState + + if (selection == null) { activity?.detachBackPressedCallback("Downloads") - downloadsViewModel.clearSelectedItems() - binding?.downloadChildToolbar?.isVisible = true + return@observeNullable + } + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() } - } - 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() + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - val allSelected = downloadsViewModel.isAllSelected() + binding.btnDelete.isVisible = selection.isNotEmpty() + binding.selectItemsText.isVisible = selection.isEmpty() + + val allSelected = downloadViewModel.isAllChildrenSelected() if (allSelected) { - binding?.btnToggleAll?.setText(R.string.deselect_all) - } else binding?.btnToggleAll?.setText(R.string.select_all) + binding.btnToggleAll.setText(R.string.deselect_all) + } else binding.btnToggleAll.setText(R.string.select_all) } val adapter = DownloadAdapter( @@ -139,18 +147,18 @@ class DownloadChildFragment : Fragment() { { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadsViewModel.handleSingleDelete(ctx, click.data.id) + downloadViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadsViewModel.addSelected(itemId) - } else downloadsViewModel.removeSelected(itemId) + downloadViewModel.addSelected(itemId) + } else downloadViewModel.removeSelected(itemId) } ) - binding?.downloadChildList?.apply { + binding.downloadChildList.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter @@ -160,43 +168,6 @@ class DownloadChildFragment : Fragment() { 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("Downloads") { - 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) { 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 2010fe7e3..abc432ef9 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 @@ -7,13 +7,8 @@ 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 @@ -22,23 +17,28 @@ import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider +import androidx.fragment.app.activityViewModels 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.Resource import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel 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.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback @@ -46,7 +46,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV @@ -54,9 +54,12 @@ import java.net.URI const val DOWNLOAD_NAVIGATE_TO = "downloadpage" -class DownloadFragment : Fragment() { - private lateinit var downloadsViewModel: DownloadViewModel - private var binding: FragmentDownloadsBinding? = null +class DownloadFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate) +) { + + private val downloadViewModel: DownloadViewModel by activityViewModels() + private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -69,120 +72,135 @@ class DownloadFragment : Fragment() { override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") - binding = null super.onDestroyView() } - override fun onCreateView( - 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 + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentDownloadsBinding) { hideKeyboard() - binding?.downloadAppbar?.setAppBarNoScrollFlagsOnTV() - binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() + binding.downloadAppbar.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(downloadViewModel.headerCards) { cards -> + when (cards) { + is Resource.Success -> { + (binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value) + binding.textNoDownloads.isVisible = cards.value.isEmpty() + binding.downloadLoading.isVisible = false + binding.downloadList.isVisible = true + } - /** - * 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() + is Resource.Loading -> { + binding.downloadList.isVisible = false + binding.downloadLoading.isVisible = true + } - observe(downloadsViewModel.headerCards) { - (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it) - binding?.downloadLoading?.isVisible = false - binding?.textNoDownloads?.isVisible = it.isEmpty() - } - observe(downloadsViewModel.availableBytes) { - updateStorageInfo( - view.context, - it, - R.string.free_storage, - binding?.downloadFreeTxt, - binding?.downloadFree - ) - } - observe(downloadsViewModel.usedBytes) { - updateStorageInfo( - view.context, - it, - R.string.used_storage, - binding?.downloadUsedTxt, - binding?.downloadUsed - ) - - val hasBytes = it > 0 - if(hasBytes) { - binding?.downloadLoadingBytes?.stopShimmer() - } else { - binding?.downloadLoadingBytes?.startShimmer() - } - - binding?.downloadBytesBar?.isVisible = hasBytes - binding?.downloadLoadingBytes?.isGone = hasBytes - } - 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) { - activity?.detachBackPressedCallback("Downloads") - downloadsViewModel.clearSelectedItems() - // Prevent race condition and make sure - // we don't display it early - if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) { - binding?.downloadAppbar?.isVisible = true + is Resource.Failure -> { + binding.downloadList.isVisible = true + binding.downloadLoading.isVisible = false } } } - observe(downloadsViewModel.selectedItemIds) { - handleSelectedChange(it) - updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - binding?.btnDelete?.isVisible = it.isNotEmpty() - binding?.selectItemsText?.isVisible = it.isEmpty() + observe(downloadViewModel.availableBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.free_storage, + binding.downloadFreeTxt, + binding.downloadFree + ) + } + observe(downloadViewModel.usedBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.used_storage, + binding.downloadUsedTxt, + binding.downloadUsed + ) - val allSelected = downloadsViewModel.isAllSelected() + val hasBytes = it > 0 + if (hasBytes) { + binding.downloadLoadingBytes.stopShimmer() + } else binding.downloadLoadingBytes.startShimmer() + + binding.downloadBytesBar.isVisible = hasBytes + binding.downloadLoadingBytes.isGone = hasBytes + } + observe(downloadViewModel.downloadBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.app_storage, + binding.downloadAppTxt, + binding.downloadApp + ) + } + observe(downloadQueueViewModel.childCards) { cards -> + val size = cards.currentDownloads.size + cards.queue.size + val context = binding.root.context + val baseText = context.getString(R.string.download_queue) + binding.downloadQueueText.text = if (size > 0) { + "$baseText (${cards.currentDownloads.size}/$size)" + } else { + baseText + } + } + + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) + } + + binding.apply { + btnDelete.setOnClickListener { view -> + downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) + } + + btnCancel.setOnClickListener { + downloadViewModel.cancelSelection() + } + + btnToggleAll.setOnClickListener { + val allSelected = downloadViewModel.isAllHeadersSelected() + if (allSelected) { + downloadViewModel.clearSelectedItems() + } else { + downloadViewModel.selectAllHeaders() + } + } + } + + observeNullable(downloadViewModel.selectedItemIds) { selection -> + val isMultiDeleteState = selection != null + val adapter = binding.downloadList.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding.downloadDeleteAppbar.isVisible = isMultiDeleteState + binding.downloadAppbar.isGone = isMultiDeleteState + + if (selection == null) { + activity?.detachBackPressedCallback("Downloads") + return@observeNullable + } + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) + + binding.btnDelete.isVisible = selection.isNotEmpty() + binding.selectItemsText.isVisible = selection.isEmpty() + + val allSelected = downloadViewModel.isAllHeadersSelected() if (allSelected) { - binding?.btnToggleAll?.setText(R.string.deselect_all) - } else binding?.btnToggleAll?.setText(R.string.select_all) + binding.btnToggleAll.setText(R.string.deselect_all) + } else binding.btnToggleAll.setText(R.string.select_all) } val adapter = DownloadAdapter( @@ -190,29 +208,29 @@ class DownloadFragment : Fragment() { { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadsViewModel.handleSingleDelete(ctx, click.data.id) + downloadViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadsViewModel.addSelected(itemId) - } else downloadsViewModel.removeSelected(itemId) + downloadViewModel.addSelected(itemId) + } else downloadViewModel.removeSelected(itemId) } ) - binding?.downloadList?.apply { + binding.downloadList.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextDown = FOCUS_SELF, + nextDown = R.id.download_queue_button, ) } - binding?.apply { + binding.apply { openLocalVideoButton.apply { isGone = isLayout(TV) setOnClickListener { openLocalVideo() } @@ -222,6 +240,10 @@ class DownloadFragment : Fragment() { setOnClickListener { showStreamInputDialog(it.context) } } + downloadQueueButton.setOnClickListener { + activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue) + } + downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) downloadAppbar.isFocusableInTouchMode = isLayout(TV) @@ -230,13 +252,12 @@ class DownloadFragment : Fragment() { } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> handleScroll(scrollY - oldScrollY) } } - context?.let { downloadsViewModel.updateHeaderList(it) } - fixPaddingStatusbar(binding?.downloadRoot) + context?.let { downloadViewModel.updateHeaderList(it) } } private fun handleItemClick(click: DownloadHeaderClickEvent) { @@ -258,40 +279,6 @@ class DownloadFragment : Fragment() { } } - private fun handleSelectedChange(selected: MutableSet) { - if (selected.isNotEmpty()) { - binding?.downloadDeleteAppbar?.isVisible = true - binding?.downloadAppbar?.isVisible = false - activity?.attachBackPressedCallback("Downloads") { - 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 = @@ -362,7 +349,8 @@ class DownloadFragment : Fragment() { listOf(BasicLink(url)), extract = true, refererUrl = referer, - ) + id = url.hashCode() + ), 0 ) ) dialog.dismissSafe(activity) @@ -393,7 +381,7 @@ class DownloadFragment : Fragment() { ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult - val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult + val selectedVideoUri = result.data?.data ?: return@registerForActivityResult playUri(activity ?: return@registerForActivityResult, selectedVideoUri) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 137f1355e..0d35d5670 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 @@ -5,91 +5,119 @@ import android.content.DialogInterface import android.os.Environment import android.os.StatFs import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.api.Log import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.ConsistentLiveData +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP 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.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched +import com.lagradost.cloudstream3.utils.ResourceLiveData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { + companion object { + const val TAG = "DownloadViewModel" + } - private val _headerCards = MutableLiveData>() - val headerCards: LiveData> = _headerCards + private val _headerCards = + ResourceLiveData>(Resource.Loading()) + val headerCards: LiveData>> = _headerCards - private val _childCards = MutableLiveData>() - val childCards: LiveData> = _childCards + private val _childCards = ResourceLiveData>(Resource.Loading()) + val childCards: LiveData>> = _childCards - private val _usedBytes = MutableLiveData() + private val _usedBytes = ConsistentLiveData() val usedBytes: LiveData = _usedBytes - private val _availableBytes = MutableLiveData() + private val _availableBytes = ConsistentLiveData() val availableBytes: LiveData = _availableBytes - private val _downloadBytes = MutableLiveData() + private val _downloadBytes = ConsistentLiveData() val downloadBytes: LiveData = _downloadBytes - private val _selectedBytes = MutableLiveData(0) + private val _selectedBytes = ConsistentLiveData(0) val selectedBytes: LiveData = _selectedBytes - private val _isMultiDeleteState = MutableLiveData(false) - val isMultiDeleteState: LiveData = _isMultiDeleteState + private val _selectedItemIds = ConsistentLiveData?>(null) + val selectedItemIds: LiveData?> = _selectedItemIds - private val _selectedItemIds = MutableLiveData>(mutableSetOf()) - val selectedItemIds: LiveData> = _selectedItemIds - private var previousVisual: List? = null - - fun setIsMultiDeleteState(value: Boolean) { - _isMultiDeleteState.postValue(value) + fun cancelSelection() { + updateSelectedItems { null } } fun addSelected(itemId: Int) { - updateSelectedItems { it.add(itemId) } + updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } } fun removeSelected(itemId: Int) { - updateSelectedItems { it.remove(itemId) } + updateSelectedItems { it?.minus(itemId) ?: emptySet() } } - fun selectAllItems() { - val items = headerCards.value.orEmpty() + childCards.value.orEmpty() - updateSelectedItems { it.addAll(items.map { item -> item.data.id }) } + fun selectAllHeaders() { + updateSelectedItems { + _headerCards.success.orEmpty() + .map { item -> item.data.id }.toSet() + } + } + + fun selectAllChildren() { + updateSelectedItems { + _childCards.success.orEmpty() + .map { item -> item.data.id }.toSet() + } } fun clearSelectedItems() { // We need this to be done immediately // so we can't use postValue - _selectedItemIds.value = mutableSetOf() - updateSelectedItems { it.clear() } + updateSelectedItems { emptySet() } } - fun isAllSelected(): Boolean { + fun isAllChildrenSelected(): 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 } + val children = _childCards.success.orEmpty() + return currentSelected.size == children.size && children.all { it.data.id in currentSelected } } - private fun updateSelectedItems(action: (MutableSet) -> Unit) { - val currentSelected = selectedItemIds.value ?: mutableSetOf() - action(currentSelected) + fun isAllHeadersSelected(): Boolean { + val currentSelected = selectedItemIds.value ?: return false + val headers = _headerCards.success.orEmpty() + return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected } + } + + private fun updateSelectedItems(action: (Set?) -> Set?) { + val currentSelected = action(selectedItemIds.value) _selectedItemIds.postValue(currentSelected) + postHeaders() + postChildren() updateSelectedBytes() - updateSelectedCards() } private fun updateSelectedBytes() = viewModelScope.launchSafe { @@ -98,61 +126,173 @@ class DownloadViewModel : ViewModel() { _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 + fun removeRedundantEpisodeKeys(context: Context, keys: List>) { + val settingsManager = context.getSharedPrefs() + ioSafe { + settingsManager.edit { + keys.forEach { (parentId, childId) -> + Log.i(TAG, "Removing download episode key: ${parentId}/${childId}") + val oldPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + parentId.toString() + ), + childId.toString() + ) + val newPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE_BACKUP, + parentId.toString() + ), + childId.toString() + ) + + val oldPref = settingsManager.getString(oldPath, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPath) + } } - _headerCards.postValue(headers) } + } - childCards.value?.let { children -> - children.forEach { child -> - child.isSelected = child.data.id in currentSelected + fun removeRedundantHeaderKeys( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + totalDownloads: Map + ) { + val settingsManager = context.getSharedPrefs() + ioSafe { + // Do not remove headers used by resume watching + val resumeWatchingIds = + getAllResumeStateIds()?.mapNotNull { id -> + getLastWatched(id)?.parentId + }?.toSet() ?: emptySet() + + settingsManager.edit { + cached.forEach { header -> + val downloads = totalDownloads[header.id] ?: 0 + val bytes = totalBytesUsedByChild[header.id] ?: 0 + + if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) { + Log.i(TAG, "Removing download header key: ${header.id}") + val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString()) + val newPath = + getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString()) + val oldPref = settingsManager.getString(oldPAth, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPAth) + } + } } - _childCards.postValue(children) } } fun updateHeaderList(context: Context) = viewModelScope.launchSafe { - val visual = withContext(Dispatchers.IO) { + // Do not push loading as it interrupts the UI + //_headerCards.postValue(Resource.Loading()) + + val visual = ioWork { val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates - val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = + val isCurrentlyDownloading = + DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() + + val downloadStats = calculateDownloadStats(context, children) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } + + // Download stats and header keys may change when downloading. + // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading. + if (!isCurrentlyDownloading) { + removeRedundantHeaderKeys( + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.totalDownloads + ) + } + // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required + removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads) createVisualDownloadList( - context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.currentBytesUsedByChild, + downloadStats.totalDownloads ) } - if (visual != previousVisual) { - previousVisual = visual - updateStorageStats(visual) - _headerCards.postValue(visual) - } + updateStorageStats(visual) + postHeaders(visual) } + fun postHeaders(newValue: List? = null) { + val newValue = newValue ?: _headerCards.success ?: return + val selection = selectedItemIds.value ?: emptySet() + _headerCards.postValue(Resource.Success(newValue.map { + it.copy( + isSelected = selection.contains( + it.data.id + ) + ) + })) + } + + fun postChildren(newValue: List? = null) { + val newValue = newValue ?: _childCards.success ?: return + val selection = selectedItemIds.value ?: emptySet() + _childCards.postValue(Resource.Success(newValue.map { + it.copy( + isSelected = selection.contains( + it.data.id + ) + ) + })) + } + + private data class DownloadStats( + val totalBytesUsedByChild: Map, + val currentBytesUsedByChild: Map, + val totalDownloads: Map, + /** Parent ID to child ID. Keys to be removed. */ + val redundantDownloads: List> + ) + private fun calculateDownloadStats( context: Context, - children: List - ): Triple, Map, Map> { + children: List + ): DownloadStats { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() // parentId : bytes val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount val totalDownloads = mutableMapOf() + val redundantDownloads = mutableListOf>() children.forEach { child -> - val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach + val childFile = getDownloadFileInfo(context, child.id) + + if (childFile == null) { + // It may not be a redundant child if something is currently downloading. + // DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader + // leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE + if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) { + redundantDownloads.add(child.parentId to child.id) + } + return@forEach + } if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes @@ -162,12 +302,17 @@ class DownloadViewModel : ViewModel() { currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) totalDownloads.merge(child.parentId, 1, Int::plus) } - return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) + return DownloadStats( + totalBytesUsedByChild, + currentBytesUsedByChild, + totalDownloads, + redundantDownloads + ) } private fun createVisualDownloadList( context: Context, - cached: List, + cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map @@ -176,13 +321,17 @@ class DownloadViewModel : ViewModel() { 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 + + 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()) - ) + val movieEpisode = + if (it.type.isEpisodeBased()) null else context.getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(it.id.toString(), it.id.toString()) + ) VisualDownloadCached.Header( currentBytes = currentBytes, @@ -208,12 +357,14 @@ class DownloadViewModel : ViewModel() { } fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { + _childCards.postValue(Resource.Loading()) // always push loading + val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> - context.getKey(key) + context.getKey(key) }.mapNotNull { val isSelected = selectedItemIds.value?.contains(it.id) ?: false - val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null + val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null VisualDownloadCached.Child( currentBytes = info.fileLength, totalBytes = info.totalBytes, @@ -221,24 +372,21 @@ class DownloadViewModel : ViewModel() { data = it, ) } - }.sortedWith(compareBy( - // Sort by season first, and then by episode number, - // to ensure sorting is consistent. - { it.data.season ?: 0 }, - { it.data.episode } - )) + }.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) - } + postChildren(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) + _selectedItemIds.postValue(null) + postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) + postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) } private fun updateStorageStats(visual: List) { @@ -292,7 +440,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -316,7 +464,7 @@ class DownloadViewModel : ViewModel() { is VisualDownloadCached.Child -> { ids.add(item.data.id) - val parent = context.getKey( + val parent = context.getKey( DOWNLOAD_HEADER_CACHE, item.data.parentId.toString() ) @@ -345,16 +493,16 @@ class DownloadViewModel : ViewModel() { .joinToString(separator = "\n") { "• $it" } return when { + data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { + context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) + } + 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) @@ -383,7 +531,6 @@ class DownloadViewModel : ViewModel() { 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 @@ -414,8 +561,8 @@ class DownloadViewModel : ViewModel() { } private fun getSelectedItemsData(): List? { - val headers = headerCards.value.orEmpty() - val children = childCards.value.orEmpty() + val headers = _headerCards.success.orEmpty() + val children = _childCards.success.orEmpty() return selectedItemIds.value?.mapNotNull { id -> headers.find { it.data.id == id } ?: children.find { it.data.id == id } @@ -423,10 +570,11 @@ class DownloadViewModel : ViewModel() { } private fun getItemDataFromId(itemId: Int): List { - val headers = headerCards.value.orEmpty() - val children = childCards.value.orEmpty() + return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } + } - return (headers + children).filter { it.data.id == itemId } + fun clearChildren() { + _childCards.postValue(Resource.Loading()) } private data class DeleteData( 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 index 908e3a80a..382a770cd 100644 --- 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 @@ -11,7 +11,7 @@ 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 +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType @@ -62,6 +62,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : open fun resetViewData() { // lastRequest = null + progressText = null isZeroBytes = true doSetProgress = true persistentId = null @@ -75,10 +76,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : currentMetaData.id = id if (!doSetProgress) return + val appContext = context.applicationContext ioSafe { - val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) - + val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id) mainWork { if (savedData != null) { val downloadedBytes = savedData.fileLength @@ -86,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) - } else run { resetView() } + } } } } @@ -215,4 +216,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : * 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 index 20a444611..91c5dd72c 100644 --- 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 @@ -8,7 +8,7 @@ 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 +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { @@ -18,6 +18,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) mainText = findViewById(R.id.result_movie_download_text) + setStatus(null) } override fun setStatus(status: DownloadStatusTell?) { @@ -35,7 +36,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { 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 index 29c2daa2c..f6f8a5ff8 100644 --- 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 @@ -10,11 +10,14 @@ import android.widget.ImageView import android.widget.TextView import androidx.annotation.MainThread import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes import androidx.core.view.isGone import androidx.core.view.isVisible -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING 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 @@ -23,9 +26,10 @@ 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 +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { @@ -63,7 +67,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open fun onInflate() {} init { - context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { + context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { try { inflate( overrideLayout ?: getResourceId( @@ -72,6 +76,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) ) } catch (e: Exception) { + recycle() // Manually call recycle first to avoid memory leaks 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" @@ -79,11 +84,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : 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 @@ -92,16 +92,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : 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 @@ -129,19 +126,29 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) 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() + + progressBar = findViewById(R.id.progress_downloaded) + progressBarBackground = findViewById(R.id.progress_downloaded_background) + statusView = findViewById(R.id.image_download_status) + + progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) + + // resetView() onInflate() } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Re-run all animations when the view gets visible. + // Otherwise views may run without animations after recycled + setStatusInternal(currentStatus) + } + private var currentStatus: DownloadStatusTell? = null /*private fun getActivity(): Activity? { var context = context @@ -162,16 +169,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, + view: View, textView: TextView?, card: DownloadObjects.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)) + val localQueue = queue.value + val localInstances = downloadInstances.value + val id = card.id + + // If the download is already in queue or active downloads, provide an option to cancel it + if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { + it.popupMenuNoIcons( + arrayListOf( + Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), + ) + ) { + callback(DownloadClickEvent(itemId, card)) + } + } else { + // Otherwise just start a download instantly + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + } } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), @@ -212,7 +234,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } open fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { @@ -282,8 +304,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : override fun setStatus(status: DownloadStatusTell?) { currentStatus = status - // Runs on the main thread, but also instant if it already is - if (Looper.myLooper() == Looper.getMainLooper()) { + // Runs on the main thread, but also instant if it already is. + if (Looper.getMainLooper().isCurrentThread) { try { setStatusInternal(status) } catch (t: Throwable) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt new file mode 100644 index 000000000..877fcfea8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt @@ -0,0 +1,274 @@ +package com.lagradost.cloudstream3.ui.download.queue + + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO + +/** An item in the adapter can either be a separator or a real item. + * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ +class DownloadAdapterItem(val item: DownloadQueueWrapper?) { + val isSeparator = item == null +} + + +class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.item?.id == b.item?.id }, + contentSame = { a, b -> + a.item == b.item + }) +) { + var currentDownloads = 0 + + companion object { + val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: DownloadAdapterItem, + position: Int + ) { + when (val binding = holder.view) { + is DownloadQueueItemBinding -> { + if (item.item == null) { + holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG + bindSeparator(binding) + } else { + holder.itemView.tag = null + bind(binding, item.item) + } + } + } + } + + fun submitQueue(newQueue: DownloadAdapterQueue) { + val index = newQueue.currentDownloads.size + val current = newQueue.currentDownloads + val queue = newQueue.queue + currentDownloads = current.size + + val newList = + (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() + .apply { + // Only add the separator if it actually separates something + if (index < this.size) { + add(index, DownloadAdapterItem(null)) + } + } + submitList(newList) + } + + fun bindSeparator(binding: DownloadQueueItemBinding) { + binding.apply { + separatorHolder.isGone = false + downloadChildEpisodeHolder.isGone = true + } + } + + fun bind( + binding: DownloadQueueItemBinding, + queueWrapper: DownloadQueueWrapper, + ) { + val context = binding.root.context + + binding.apply { + separatorHolder.isGone = true + downloadChildEpisodeHolder.isGone = false + + // Only set the child-text if child and parent are not the same + // This prevents setting movie titles twice + if (queueWrapper.id != queueWrapper.parentId) { + val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName + downloadChildEpisodeTextExtra.text = mainName + } else { + downloadChildEpisodeTextExtra.text = null + } + + downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() + + val status = VideoDownloadManager.downloadStatus[queueWrapper.id] + + downloadButton.setOnClickListener { view -> + val episodeCached = + getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) + ) + + val downloadInfo = context.getKey( + KEY_DOWNLOAD_INFO, + queueWrapper.id.toString() + ) + + val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() + + val actionList = arrayListOf>() + + if (isCurrentlyDownloading && episodeCached != null) { + // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything + if (downloadInfo != null) { + actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + } + + val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] + + when (currentStatus) { + VideoDownloadManager.DownloadType.IsDownloading -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_PAUSE_DOWNLOAD, + R.string.popup_pause_download + ) + ) + } + + VideoDownloadManager.DownloadType.IsPaused -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + + else -> {} + } + + view.popupMenuNoIcons( + actionList + ) { + handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) + } + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + + view.popupMenuNoIcons( + actionList + ) { + when (itemId) { + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(queueWrapper.id) + } + } + } + } + } + + downloadButton.resetView() + downloadButton.setStatus(status) + downloadButton.setPersistentId(queueWrapper.id) + + downloadChildEpisodeText.apply { + val name = queueWrapper.downloadItem?.episode?.name + ?: queueWrapper.resumePackage?.item?.ep?.name + val episode = + queueWrapper.downloadItem?.episode?.episode + ?: queueWrapper.resumePackage?.item?.ep?.episode + val season = + queueWrapper.downloadItem?.episode?.season + ?: queueWrapper.resumePackage?.item?.ep?.season + text = context.getNameFull(name, episode, season) + isSelected = true // Needed for text repeating + } + } + } +} + + +class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : + ItemTouchHelper( + DragAndDropTouchHelperCallback(adapter) + ) + +private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : + ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + val isDownloading = item.item?.isCurrentlyDownloading() == true + val dragFlags = if (item.isSeparator || isDownloading) { + 0 + } else { + ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down + } + + val swipeFlags = 0 // Disable swipe functionality + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove( + recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = source.absoluteAdapterPosition + val toPosition = target.absoluteAdapterPosition + val separatorPosition = adapter.currentDownloads + + val toPositionNoSeparator = + if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition + + if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { + return false + } else { + adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> + DownloadQueueManager.reorderItem( + downloadQueueInfo, + toPositionNoSeparator - 1 + ) + } + } + + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + + } + + override fun isLongPressDragEnabled(): Boolean { + return true // Enable drag with long press + } + + override fun isItemViewSwipeEnabled(): Boolean { + return false // Disable swipe by default + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt new file mode 100644 index 000000000..071d8913d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,79 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment +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.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.txt + + +class DownloadQueueFragment : + BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { + private val queueViewModel: DownloadQueueViewModel by activityViewModels() + + override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { + val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) + val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) + + observe(queueViewModel.childCards) { cards -> + val size = cards.queue.size + cards.currentDownloads.size + val isEmptyQueue = size == 0 + binding.downloadQueueList.isGone = isEmptyQueue + binding.textNoQueue.isGone = !isEmptyQueue + clearQueueItem?.isVisible = !isEmptyQueue + + adapter.submitQueue(cards) + } + + binding.apply { + downloadQueueToolbar.apply { + title = txt(R.string.download_queue).asString(context) + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() + } + } + setAppBarNoScrollFlagsOnTV() + clearQueueItem?.setOnMenuItemClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setTitle(R.string.cancel_all) + .setMessage(R.string.cancel_queue_message) + .setPositiveButton(R.string.yes) { _, _ -> + DownloadQueueManager.removeAllFromQueue() + } + .setNegativeButton(R.string.no) { _, _ -> + }.show() + + true + } + } + + downloadQueueList.adapter = adapter + + // Drag and drop + val helper = DragAndDropTouchHelper(adapter) + helper.attachToRecyclerView(downloadQueueList) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt new file mode 100644 index 000000000..fc384cb4e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +data class DownloadAdapterQueue( + val currentDownloads: List, + val queue: List, +) + +class DownloadQueueViewModel : ViewModel() { + private val _childCards = MutableLiveData() + val childCards: LiveData = _childCards + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + val current = instances.map { it.downloadQueueWrapper } + DownloadAdapterQueue(current, queue.toList()) + }.combine(VideoDownloadManager.currentDownloads) { total, _ -> + // We want to update the flow when currentDownloads updates, but we do not care about its value + total + } + + init { + viewModelScope.launch { + totalDownloadFlow.collect { queue -> + updateChildList(queue) + } + } + } + + fun updateChildList(downloads: DownloadAdapterQueue) { + _childCards.postValue(downloads) + } +} \ 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 ae22afdb2..43f6d19ff 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,9 +1,10 @@ package com.lagradost.cloudstream3.ui.home +import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment +import android.widget.FrameLayout import androidx.preference.PreferenceManager import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R @@ -13,7 +14,9 @@ import com.lagradost.cloudstream3.databinding.HomeRemoveGridExpandedBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder @@ -41,13 +44,11 @@ class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(vi } class ResumeItemAdapter( - fragment: Fragment, nextFocusUp: Int? = null, nextFocusDown: Int? = null, clickCallback: (SearchClickCallback) -> Unit, private val removeCallback: (View) -> Unit, ) : HomeChildItemAdapter( - fragment = fragment, id = "resumeAdapter".hashCode(), nextFocusUp = nextFocusUp, nextFocusDown = nextFocusDown, @@ -67,20 +68,32 @@ class ResumeItemAdapter( return HomeScrollViewHolderState(binding) } + override fun onClearView(holder: ViewHolderState) { + // Clear the image, idk if this saves ram or not, but I guess? + clearImage(holder.view.root.findViewById(R.id.imageView)) + } + override fun onBindFooter(holder: ViewHolderState) { this.applyBinding(holder, false) + when (val binding = holder.view) { + is HomeRemoveGridBinding -> { + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + } + + is HomeRemoveGridExpandedBinding -> { + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + } + } holder.itemView.apply { if (isLayout(TV)) { isFocusableInTouchMode = true isFocusable = true } - - if (nextFocusUp != null) { - nextFocusUpId = nextFocusUp + nextFocusUp?.let { + nextFocusUpId = it } - - if (nextFocusDown != null) { - nextFocusDownId = nextFocusDown + nextFocusDown?.let { + nextFocusDownId = it } setOnClickListener { v -> @@ -90,16 +103,49 @@ class ResumeItemAdapter( } } +/** Remember to set `updatePosterSize` to cache the poster size, + * otherwise the width and height is unset */ open class HomeChildItemAdapter( - fragment: Fragment, id: Int, - protected val nextFocusUp: Int? = null, - protected val nextFocusDown: Int? = null, - private val clickCallback: (SearchClickCallback) -> Unit, + var nextFocusUp: Int? = null, + var nextFocusDown: Int? = null, + var clickCallback: (SearchClickCallback) -> Unit, ) : - BaseAdapter(fragment, id) { - var isHorizontal: Boolean = false + BaseAdapter( + id, diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.url == b.url && a.name == b.name + }, + contentSame = { a, b -> + a == b + }) + ) { var hasNext: Boolean = false + var isHorizontal: Boolean = false + set(value) { + field = value + updateCachedPosterSize() + } + + private fun updateCachedPosterSize() { + setWidth = if (!isHorizontal) { + minPosterSize + } else { + maxPosterSize + } + setHeight = if (!isHorizontal) { + maxPosterSize + } else { + minPosterSize + } + } + + init { + updateCachedPosterSize() + } + + protected var setWidth = 0 + protected var setHeight = 0 override fun onCreateContent(parent: ViewGroup): ViewHolderState { val expanded = parent.context.isBottomLayout() @@ -112,52 +158,43 @@ open class HomeChildItemAdapter( return HomeScrollViewHolderState(binding) } - protected fun applyBinding(holder: ViewHolderState, isFirstItem: Boolean) { - val context = holder.view.root.context - val scale = PreferenceManager.getDefaultSharedPreferences(context) - ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 - // Scale by +10% per step - val mul = 1.0f + scale * 0.1f - val min = (114.toPx.toFloat() * mul).toInt() - val max = (180.toPx.toFloat() * mul).toInt() + companion object { + // The vast majority of the lag comes from creating the view + // This simply shares the views between all HomeChildItemAdapter + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 20) } + var minPosterSize: Int = 0 + var maxPosterSize: Int = 0 + + fun updatePosterSize(context: Context, value: Int? = null) { + val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context) + ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 + // Scale by +10% per step + val mul = 1.0f + scale * 0.1f + minPosterSize = (114.toPx.toFloat() * mul).toInt() + maxPosterSize = (180.toPx.toFloat() * mul).toInt() + } + + fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) { + val params = layout.layoutParams + if (params.height == height && params.width == width) return + + params.width = width + params.height = height + + layout.layoutParams = params + } + } + + protected fun applyBinding(holder: ViewHolderState, isFirstItem: Boolean) { when (val binding = holder.view) { is HomeResultGridBinding -> { - binding.backgroundCard.apply { - - layoutParams = - layoutParams.apply { - width = if (!isHorizontal) { - min - } else { - max - } - height = if (!isHorizontal) { - max - } else { - min - } - } - } + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) } is HomeResultGridExpandedBinding -> { - binding.backgroundCard.apply { - - layoutParams = - layoutParams.apply { - width = if (!isHorizontal) { - min - } else { - max - } - height = if (!isHorizontal) { - max - } else { - min - } - } - } + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) if (isFirstItem) { // to fix tv binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view 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 35c7e1271..b68ef5962 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 @@ -5,26 +5,36 @@ import android.app.Activity import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.content.res.Configuration -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.ComponentActivity import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.core.view.isGone +import androidx.core.view.isInvisible 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.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip -import com.lagradost.cloudstream3.* +import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.apis +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding @@ -35,13 +45,18 @@ 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.BaseFragment import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.utils.txt -import com.lagradost.cloudstream3.ui.search.* +import com.lagradost.cloudstream3.ui.account.AccountViewModel +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE +import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback +import com.lagradost.cloudstream3.ui.setRecycledViewPool 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.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -51,22 +66,30 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.EmptyEvent import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import java.util.* +import com.lagradost.cloudstream3.utils.UIHelper.toPx +private const val TAG = "HomeFragment" -class HomeFragment : Fragment() { +class HomeFragment : BaseFragment( + BindingCreator.Bind(FragmentHomeBinding::bind) +) { companion object { - val configEvent = Event() + // Used for configuration changed events to fix any popups that are not attached to a fragment + val configEvent = EmptyEvent() var currentSpan = 1 - val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -95,6 +118,7 @@ class HomeFragment : Fragment() { //} // returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView + fun Activity.loadHomepageList( expand: HomeViewModel.ExpandableHomepageList, deleteCallback: (() -> Unit)? = null, @@ -176,16 +200,17 @@ class HomeFragment : Fragment() { // Span settings - binding.homeExpandedRecycler.spanCount = currentSpan - + binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) + binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool) binding.homeExpandedRecycler.adapter = - SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback -> + SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { 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 { + submitList(item.list) hasNext = expand.hasNext } @@ -209,7 +234,7 @@ class HomeFragment : Fragment() { expandCallback?.invoke(name)?.let { newExpand -> (recyclerView.adapter as? SearchAdapter?)?.apply { hasNext = newExpand.hasNext - updateList(newExpand.list.list) + submitList(newExpand.list.list) } } } @@ -217,9 +242,12 @@ class HomeFragment : Fragment() { } }) - val spanListener = { span: Int -> - binding.homeExpandedRecycler.spanCount = span - //(recycle.adapter as SearchAdapter).notifyDataSetChanged() + val spanListener = Runnable { + binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) + // We want to rebind everything to update the UI, however we also want to avoid + // any animations ect, this is the easiest way to do this, and the most correct + @SuppressLint("NotifyDataSetChanged") + binding.homeExpandedRecycler.adapter?.notifyDataSetChanged() } configEvent += spanListener @@ -289,7 +317,7 @@ class HomeFragment : Fragment() { val pairList = getPairList(header) for ((button, types) in pairList) { button?.isChecked = - button?.isVisible == true && selectedTypes.any { types.contains(it) } + button.isVisible && selectedTypes.any { types.contains(it) } } } @@ -383,16 +411,23 @@ class HomeFragment : Fragment() { val listView = dialog.findViewById(R.id.listview1) - val arrayAdapter = object : ArrayAdapter(this, R.layout.sort_bottom_single_provider_choice, + val arrayAdapter = object : ArrayAdapter( + this, R.layout.sort_bottom_single_provider_choice, mutableListOf() ) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.sort_bottom_single_provider_choice, parent, false) + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup + ): View { + val view = convertView ?: LayoutInflater.from(context) + .inflate(R.layout.sort_bottom_single_provider_choice, parent, false) val titleText = view.findViewById(R.id.text1) val pinIcon = view.findViewById(R.id.pinicon) val name = getItem(position) titleText?.text = name - val isPinned = pinnedphashset.contains(currentValidApis[position].name ?: "") + val isPinned = + pinnedphashset.contains(currentValidApis[position].name) pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE return view } @@ -404,7 +439,7 @@ class HomeFragment : Fragment() { if (currentValidApis.isNotEmpty()) { currentApiName = currentValidApis[i].name //to switch to apply simply remove this - currentApiName?.let(callback) + currentApiName.let(callback) dialog.dismissSafe() } } @@ -415,7 +450,11 @@ class HomeFragment : Fragment() { pinnedphashset = pinnedp.toHashSet() arrayAdapter.clear() val sortedApis = validAPIs - .filter {it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any(preSelectedTypes::contains)) } + .filter { + it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any( + preSelectedTypes::contains + )) + } .sortedBy { it.name.lowercase() } val sortedApiMap = LinkedHashMap().apply { @@ -443,12 +482,12 @@ class HomeFragment : Fragment() { } // pin provider on hold listView?.setOnItemLongClickListener { _, _, i, _ -> - if (currentValidApis.isNotEmpty() && i>1) { + if (currentValidApis.isNotEmpty() && i > 1) { val pinnedp = DataStoreHelper.pinnedProviders.toMutableList() val thisapi = currentValidApis[i].name - if(pinnedp.contains(thisapi)){ + if (pinnedp.contains(thisapi)) { pinnedp.remove(thisapi) - }else{ + } else { pinnedp.add(thisapi) } DataStoreHelper.pinnedProviders = pinnedp.toTypedArray() @@ -472,47 +511,71 @@ class HomeFragment : Fragment() { } private val homeViewModel: HomeViewModel by activityViewModels() + private val accountViewModel: AccountViewModel by activityViewModels() - var binding: FragmentHomeBinding? = null + fun addMovies(cards: List) { + val ctx = context ?: run { + Log.e(TAG, "Context is null, aborting addMovies") + return + } + try { + val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) + if (existingId != null) { + Log.d(TAG, "Channel ID: $existingId") + + val programCards = cards + + TvChannelUtils.addPrograms( + context = ctx, + channelId = existingId, + items = programCards + ) + } else { + Log.d(TAG, "Channel does not exist") + } + } catch (e: Exception) { + Log.e(TAG, "Error adding movies: $e") + } + } + + private fun deleteAll() { + val ctx = context ?: run { + Log.e(TAG, "Context is null, aborting deleteAll") + return + } + + try { + val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) + if (existingId != null) { + Log.d(TAG, "Channel ID: $existingId") + TvChannelUtils.deleteStoredPrograms(ctx) + } else { + Log.d(TAG, "Channel does not exist") + } + } catch (e: Exception) { + Log.e(TAG, "Error deleting programs: ${e.message}") + } + } + + override fun pickLayout(): Int? = + if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): 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 + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { - + (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") bottomSheetDialog?.ownHide() - binding = null super.onDestroyView() } - private fun fixGrid() { - activity?.getSpanCount()?.let { - currentSpan = it - } - configEvent.invoke(currentSpan) - } - private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) @@ -526,55 +589,129 @@ class HomeFragment : Fragment() { }*/ } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - //(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged() - fixGrid() - } - private var currentApiName: String? = null private var toggleRandomButton = false private var bottomSheetDialog: BottomSheetDialog? = null private var homeMasterAdapter: HomeParentItemAdapterPreview? = null - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixGrid() + var lastSavedHomepage: String? = null - binding?.apply { + fun saveHomepageToTV(page: Map) { + // No need to update for phone + if (isLayout(PHONE)) { + return + } + val (name, data) = page.entries.firstOrNull() ?: return + // Modifying homepage is an expensive operation, and therefore we avoid it at all cost + if (name == lastSavedHomepage) { + return + } + Log.i(TAG, "Adding programs $name to TV") + lastSavedHomepage = name + ioSafe { + // empty the channel + deleteAll() + // insert the program from first array + addMovies(data.list.list) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padTop = false, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + + // Fix grid + configEvent.invoke() + } + + @SuppressLint("SetTextI18n") + override fun onBindingCreated(binding: FragmentHomeBinding) { + context?.let { HomeChildItemAdapter.updatePosterSize(it) } + (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { + handleTvBackPress(this) + } + binding.apply { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) homeApiFab.setOnClickListener(apiChangeClickListener) + homeApiFab.setOnLongClickListener { + if (currentApiName == noneApi.name) return@setOnLongClickListener false + homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } homeChangeApi.setOnClickListener(apiChangeClickListener) homeSwitchAccount.setOnClickListener { activity?.showAccountSelectLinear() } - homeRandom.setOnClickListener { - if (listHomepageItems.isNotEmpty()) { - activity.loadSearchResult(listHomepageItems.random()) - } - } homeMasterAdapter = HomeParentItemAdapterPreview( - fragment = this@HomeFragment, - homeViewModel, + homeViewModel, accountViewModel ) + homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) homeMasterRecycler.adapter = homeMasterAdapter - //fixPaddingStatusbar(homeLoadingStatusbar) homeApiFab.isVisible = isLayout(PHONE) + homePreviewReloadProvider.setOnClickListener { + homeViewModel.loadAndCancel( + homeViewModel.apiName.value ?: noneApi.name, + forceReload = true, + fromUI = true + ) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } + + homePreviewSearchButton.setOnClickListener { _ -> + // Open blank screen. + homeViewModel.queryTextSubmit("") + } + 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() + if (isLayout(PHONE)) { + // Fab is only relevant to Phone + if (dy > 0) { //check for scroll down + homeApiFab.shrink() // hide + homeRandom.shrink() + } else if (dy < -5) { + if (isLayout(PHONE)) { + homeApiFab.extend() // show + homeRandom.extend() + } + } + } else { + // Header scrolling is only relevant to TV/Emulator + + val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView + val scrollParent = binding.homeApiHolder + + if (view == null) { + // The first view is not visible, so we can assume we have scrolled past it + scrollParent.isVisible = false + } else { + // A bit weird, but this is a major limitation we are working around here + // 1. We cant have a real parent to the recyclerview as android cant layout that without lagging + // 2. We cant put the view in the recyclerview, as it should always be shown + // 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view + // + // This means that if we want to have a parent view to the recyclerview we are out of luck + // Instead this uses getLocationInWindow to calculate how much the view should be scrolled + // as recyclerView has no scrollY (always 0) + // + // Then it manually "scrolls" it to the correct position + // + // Hopefully getLocationInWindow acts correctly on all devices + val rect = IntArray(2) + view.getLocationInWindow(rect) + scrollParent.isVisible = true + scrollParent.translationY = rect[1].toFloat() - 60.toPx } } super.onScrolled(recyclerView, dx, dy) @@ -583,7 +720,6 @@ class HomeFragment : Fragment() { } - //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) @@ -591,46 +727,56 @@ class HomeFragment : Fragment() { settingsManager.getBoolean( getString(R.string.random_button_key), false - ) && isLayout(PHONE) - binding?.homeRandom?.visibility = View.GONE + ) + binding.homeRandom.visibility = View.GONE + binding.homeRandomButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - binding?.homeApiFab?.text = apiName - binding?.homeChangeApi?.text = apiName + binding.apply { + homeApiFab.text = apiName + homeChangeApi.text = apiName + homePreviewReloadProvider.isGone = (apiName == noneApi.name) + homePreviewSearchButton.isGone = (apiName == noneApi.name) + } } observe(homeViewModel.page) { data -> - binding?.apply { + binding.apply { when (data) { is Resource.Success -> { - homeLoadingShimmer.stopShimmer() - 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()) ) - }.toMutableList()) + }) + + saveHomepageToTV(d) homeLoading.isVisible = false homeLoadingError.isVisible = false homeMasterRecycler.isVisible = true + homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true if (toggleRandomButton) { - //Flatten list - d.values.forEach { dlist -> - mutableListOfResponse.addAll(dlist.list.list) + val distinct = d.values + .flatMap { it.list.list } + .distinctBy { it.url } + val hasItems = distinct.isNotEmpty() + val isPhone = isLayout(PHONE) + val randomClickListener = View.OnClickListener { + distinct.randomOrNull()?.let { activity.loadSearchResult(it) } } - listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - homeRandom.isVisible = listHomepageItems.isNotEmpty() + homeRandom.isVisible = isPhone && hasItems + homeRandom.setOnClickListener(randomClickListener) + homeRandomButtonTv.isVisible = !isPhone && hasItems + homeRandomButtonTv.setOnClickListener(randomClickListener) } else { homeRandom.isGone = true + homeRandomButtonTv.isGone = true } } @@ -648,7 +794,7 @@ class HomeFragment : Fragment() { }) { try { val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(validAPIs[itemId].mainUrl) + i.data = validAPIs[itemId].mainUrl.toUri() startActivity(i) } catch (e: Exception) { logError(e) @@ -658,7 +804,7 @@ class HomeFragment : Fragment() { homeLoading.isVisible = false homeLoadingError.isVisible = true - homeMasterRecycler.isVisible = false + homeMasterRecycler.isInvisible = true // Based on https://github.com/recloudstream/cloudstream/pull/1438 val hasNoNetworkConnection = context?.isNetworkAvailable() == false @@ -680,24 +826,28 @@ class HomeFragment : Fragment() { homeReloadConnectionGoToDownloads.setOnClickListener { activity.navigate(R.id.navigation_downloads) } + + (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { + submitList(null) + clearState() + } } is Resource.Loading -> { - (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf()) homeLoadingShimmer.startShimmer() homeLoading.isVisible = true homeLoadingError.isVisible = false - homeMasterRecycler.isVisible = false + homeMasterRecycler.isInvisible = true + (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { + submitList(null) + clearState() + } //home_loaded?.isVisible = false } } } } - - //context?.fixPaddingStatusbarView(home_statusbar) - //context?.fixPaddingStatusbar(home_padding) - observeNullable(homeViewModel.popup) { item -> if (item == null) { bottomSheetDialog?.dismissSafe() @@ -742,4 +892,44 @@ class HomeFragment : Fragment() { } }*/ } + + private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) { + // Only apply custom behavior on TV interface + if (!isLayout(TV)) { + helper.runDefault() + return + } + val currentFocus = activity?.currentFocus ?: run { + helper.runDefault() + return + } + // isInsideRecycle is true when focus is inside home_master_recycler + var parent = currentFocus.parent + var isInsideRecycler = false + while (parent != null) { + if (parent is View && parent.id == R.id.home_master_recycler) { + isInsideRecycler = true + break + } + parent = parent.parent + } + when { + // Case 1: Focus is within plugin content -> Move to plugin selector + isInsideRecycler -> { + binding?.homeMasterRecycler?.scrollToPosition(0) + // Defer focus request until after scroll ends + binding?.homeChangeApi?.post { + binding?.homeChangeApi?.requestFocus() + } + } + // Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation + currentFocus.id == R.id.home_change_api || + currentFocus.id == R.id.home_preview_reload_provider || + currentFocus.id == R.id.home_preview_search_button -> { + activity?.findViewById(R.id.navigation_home)?.requestFocus() + } + // Case 3: Any other location -> Use default back behavior + else -> helper.runDefault() + } + } } 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 8bc0aa287..6bdd1bf49 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 @@ -6,10 +6,8 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding -import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.HomepageParentBinding @@ -17,9 +15,11 @@ 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.newSharedPool import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SearchClickCallback +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -34,13 +34,11 @@ class LoadClickCallback( ) open class ParentItemAdapter( - open val fragment: Fragment, id: Int, 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 }, @@ -48,6 +46,11 @@ open class ParentItemAdapter( a.list.list == b.list.list }) ) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 4) } + } + data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview @@ -60,13 +63,16 @@ open class ParentItemAdapter( override fun restore(state: Bundle) { (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( - state.getSafeParcelable("value") + state.getSafeParcelable("value") ) } } - override fun submitList(list: List?) { - super.submitList(list?.sortedBy { it.list.list.isEmpty() }) + override fun submitList( + list: Collection?, + commitCallback: Runnable? + ) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) } override fun onUpdateContent( @@ -90,17 +96,30 @@ open class ParentItemAdapter( if (binding !is HomepageParentBinding) return val info = item.list binding.apply { - homeChildRecyclerview.adapter = HomeChildItemAdapter( - fragment = fragment, - id = id + position + 100, - clickCallback = clickCallback, - nextFocusUp = homeChildRecyclerview.nextFocusUpId, - nextFocusDown = homeChildRecyclerview.nextFocusDownId, - ).apply { - isHorizontal = info.isHorizontalImages - hasNext = item.hasNext - submitList(item.list.list) + val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter + if (currentAdapter == null) { + homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool) + homeChildRecyclerview.adapter = HomeChildItemAdapter( + id = id + position + 100, + clickCallback = clickCallback, + nextFocusUp = homeChildRecyclerview.nextFocusUpId, + nextFocusDown = homeChildRecyclerview.nextFocusDownId, + ).apply { + isHorizontal = info.isHorizontalImages + hasNext = item.hasNext + submitList(item.list.list) + } + } else { + currentAdapter.apply { + isHorizontal = info.isHorizontalImages + hasNext = item.hasNext + this.clickCallback = this@ParentItemAdapter.clickCallback + nextFocusUp = homeChildRecyclerview.nextFocusUpId + nextFocusDown = homeChildRecyclerview.nextFocusDownId + submitIncomparableList(item.list.list) + } } + homeChildRecyclerview.setLinearListLayout( isHorizontal = true, nextLeft = startFocus, @@ -166,11 +185,6 @@ open class ParentItemAdapter( return ParentItemHolder(binding) } - - fun updateList(newList: List) { - submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } - .toMutableList()) - } } @Suppress("DEPRECATION") 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 0ce7ca8f2..959806e56 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,16 +1,18 @@ package com.lagradost.cloudstream3.ui.home +import android.content.Context import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import androidx.appcompat.app.AlertDialog 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.LifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding @@ -18,9 +20,8 @@ import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup import com.google.android.material.navigation.NavigationBarItemView -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainActivity @@ -34,9 +35,11 @@ import com.lagradost.cloudstream3.mvvm.logError 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.showAccountEditDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage +import com.lagradost.cloudstream3.ui.account.AccountViewModel import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.getId @@ -47,19 +50,23 @@ 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.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage 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.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.populateChips +import androidx.core.graphics.toColorInt +import com.lagradost.cloudstream3.ui.setRecycledViewPool class HomeParentItemAdapterPreview( - override val fragment: Fragment, private val viewModel: HomeViewModel, + private val accountViewModel: AccountViewModel ) : ParentItemAdapter( - fragment, id = "HomeParentItemAdapterPreview".hashCode(), + id = "HomeParentItemAdapterPreview".hashCode(), clickCallback = { viewModel.click(it) }, moreInfoClickCallback = { @@ -97,15 +104,33 @@ class HomeParentItemAdapterPreview( ) } - return HeaderViewHolder(binding, viewModel, fragment = fragment) + return HeaderViewHolder(binding, viewModel, accountViewModel) } override fun onBindHeader(holder: ViewHolderState) { (holder as? HeaderViewHolder)?.bind() } + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + when (holder) { + is HeaderViewHolder -> { + holder.onViewDetachedFromWindow() + } + } + } + + override fun onViewAttachedToWindow(holder: ViewHolderState) { + when (holder) { + is HeaderViewHolder -> { + holder.onViewAttachedToWindow() + } + } + } + private class HeaderViewHolder( - val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment, + val binding: ViewBinding, + val viewModel: HomeViewModel, + accountViewModel: AccountViewModel, ) : ViewHolderState(binding) { @@ -131,9 +156,13 @@ class HomeParentItemAdapterPreview( } } - val previewAdapter = HomeScrollAdapter(fragment = fragment) + val previewAdapter = HomeScrollAdapter { view, position, item -> + viewModel.click( + LoadClickCallback(0, view, position, item) + ) + } + private val resumeAdapter = ResumeItemAdapter( - fragment, nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId, removeCallback = { v -> @@ -216,7 +245,6 @@ class HomeParentItemAdapterPreview( } }) private val bookmarkAdapter = HomeChildItemAdapter( - fragment, id = "bookmarkAdapter".hashCode(), nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId @@ -293,9 +321,14 @@ class HomeParentItemAdapterPreview( private val bookmarkRecyclerView: RecyclerView = itemView.findViewById(R.id.home_bookmarked_child_recyclerview) - private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account) - private val alternativeHomeAccount: View? = - itemView.findViewById(R.id.alternative_switch_account) + private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic) + private val headProfilePicCard: View? = + itemView.findViewById(R.id.home_head_profile_padding) + + private val alternateHeadProfilePic: ImageView? = + itemView.findViewById(R.id.alternate_home_head_profile_pic) + private val alternateHeadProfilePicCard: View? = + itemView.findViewById(R.id.alternate_home_head_profile_padding) private val topPadding: View? = itemView.findViewById(R.id.home_padding) @@ -306,38 +339,73 @@ class HomeParentItemAdapterPreview( fun onSelect(item: LoadResponse, position: Int) { (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewDescription.isGone = - item.plot.isNullOrBlank() - homePreviewDescription.text = - item.plot ?: "" + homePreviewDescription.isGone = item.plot.isNullOrBlank() + homePreviewDescription.text = item.plot?.html() ?: "" - homePreviewText.text = item.name + val scoreText = item.score?.toStringNull(0.1, 10, 1, false) + + scoreText?.let { score -> + homePreviewScore.text = + homePreviewScore.context.getString(R.string.extension_rating, score) + + // while it should never fail, we do this just in case + val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0 + + val color = when { + rating < 5.0 -> "#eb2f2f".toColorInt() // Red + rating < 8.0 -> "#eda009".toColorInt() // Yellow + else -> "#3bb33b".toColorInt() // Green + } + homePreviewScore.backgroundTintList = + android.content.res.ColorStateList.valueOf(color) + } + homePreviewScore.isGone = scoreText == null + + item.year?.let { year -> + homePreviewYear.text = year.toString() + } + homePreviewYear.isGone = item.year == null + + val duration = item.duration + duration?.let { min -> + homePreviewDuration.text = + homePreviewDuration.context.getString(R.string.duration_format, min) + } + homePreviewDuration.isGone = duration == null || duration <= 0 + + val castText = item.actors?.take(3)?.joinToString(", ") { it.actor.name } + if (!castText.isNullOrBlank()) { + homePreviewCast.text = + homePreviewCast.context.getString(R.string.cast_format, castText) + homePreviewCast.isVisible = true + } else { + homePreviewCast.isVisible = false + } + + homePreviewText.text = item.name.html() populateChips( homePreviewTags, item.tags?.take(6) ?: emptyList(), - R.style.ChipFilledSemiTransparent + R.style.ChipFilledSemiTransparent, + null + ) + + + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = homePreviewText, + logoView = homeBackgroundPosterWatermarkBadgeHolder ) 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) @@ -422,7 +490,7 @@ class HomeParentItemAdapterPreview( } } - override fun onViewDetachedFromWindow() { + fun onViewDetachedFromWindow() { previewViewpager.unregisterOnPageChangeCallback(previewCallback) } @@ -443,12 +511,14 @@ class HomeParentItemAdapterPreview( previewViewpager.adapter = previewAdapter resumeRecyclerView.adapter = resumeAdapter + bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool) bookmarkRecyclerView.adapter = bookmarkAdapter resumeRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF ) + bookmarkRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF @@ -469,36 +539,80 @@ class HomeParentItemAdapterPreview( } } - homeAccount?.isGone = isLayout(TV or EMULATOR) + headProfilePicCard?.isGone = isLayout(TV or EMULATOR) + alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) - homeAccount?.setOnClickListener { + (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> + headProfilePic?.loadImage(currentAccount?.image) + alternateHeadProfilePic?.loadImage(currentAccount?.image) + } + + headProfilePicCard?.setOnClickListener { activity?.showAccountSelectLinear() } - alternativeHomeAccount?.setOnClickListener { + fun showAccountEditBox(context: Context): Boolean { + val currentAccount = DataStoreHelper.getCurrentAccount() + return if (currentAccount != null) { + showAccountEditDialog( + context = context, + account = currentAccount, + isNewAccount = false, + accountEditCallback = { accountViewModel.handleAccountUpdate(it, context) }, + accountDeleteCallback = { + accountViewModel.handleAccountDelete( + it, + context + ) + } + ) + true + } else false + } + + alternateHeadProfilePicCard?.setOnLongClickListener { + showAccountEditBox(it.context) + } + headProfilePicCard?.setOnLongClickListener { + showAccountEditBox(it.context) + } + + alternateHeadProfilePicCard?.setOnClickListener { activity?.showAccountSelectLinear() } (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewChangeApi.setOnClickListener { view -> + /*homePreviewChangeApi.setOnClickListener { view -> view.context.selectHomepage(viewModel.repo?.name) { api -> viewModel.loadAndCancel(api, forceReload = true, fromUI = true) } } - + homePreviewReloadProvider.setOnClickListener { + viewModel.loadAndCancel( + viewModel.apiName.value ?: noneApi.name, + forceReload = true, + fromUI = true + ) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } homePreviewSearchButton.setOnClickListener { _ -> // Open blank screen. viewModel.queryTextSubmit("") - } + }*/ - // This makes the hidden next buttons only available when on the info button - // Otherwise you might be able to go to the next item without being at the info button - homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus -> - homePreviewHiddenNextFocus.isFocusable = hasFocus - } - - homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus -> - homePreviewHiddenPrevFocus.isFocusable = hasFocus + // A workaround to the focus problem of always centering the view on focus + // as that causes higher android versions to stretch the ui when switching between shows + var lastFocusTimeoutMs = 0L + homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus -> + val lastFocusMs = lastFocusTimeoutMs + // Always reset timer, as we only want to update + // it if we have not interacted in half a second + lastFocusTimeoutMs = System.currentTimeMillis() + if (!hasFocus) return@setOnFocusChangeListener + if (lastFocusMs + 500L < System.currentTimeMillis()) { + MainActivity.centerView(view) + } } homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> @@ -516,7 +630,8 @@ class HomeParentItemAdapterPreview( )?.requestFocus() } else { previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) - binding.homePreviewPlayBtt.requestFocus() + binding.homePreviewInfoBtt.requestFocus() + //binding.homePreviewPlayBtt.requestFocus() } } } @@ -543,9 +658,7 @@ class HomeParentItemAdapterPreview( params.height = 0 layoutParams = params } - } else { - fixPaddingStatusbarView(homeNonePadding) - } + } else fixPaddingStatusbarView(homeNonePadding) when (preview) { is Resource.Success -> { @@ -569,6 +682,15 @@ class HomeParentItemAdapterPreview( previewViewpager.isVisible = true previewViewpagerText.isVisible = true alternativeAccountPadding?.isVisible = false + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewInfoBtt.isVisible = true + } + // Explicitly bind the current item to ensure instant loading + val currentPos = previewViewpager.currentItem + val item = preview.value.second.getOrNull(currentPos) + if (item != null) { + onSelect(item, currentPos) + } } else -> { @@ -577,6 +699,9 @@ class HomeParentItemAdapterPreview( previewViewpager.isVisible = false previewViewpagerText.isVisible = false alternativeAccountPadding?.isVisible = true + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewInfoBtt.isVisible = false + } //previewHeader.isVisible = false } } @@ -645,18 +770,19 @@ class HomeParentItemAdapterPreview( } } - override fun onViewAttachedToWindow() { + fun onViewAttachedToWindow() { previewViewpager.registerOnPageChangeCallback(previewCallback) - binding.root.findViewTreeLifecycleOwner()?.apply { + previewViewpager.apply { observe(viewModel.preview) { updatePreview(it) } - if (binding is FragmentHomeHeadTvBinding) { + /*if (binding is FragmentHomeHeadTvBinding) { observe(viewModel.apiName) { name -> binding.homePreviewChangeApi.text = name + binding.homePreviewReloadProvider.isGone = (name == noneApi.name) } - } + }*/ observe(viewModel.resumeWatching) { updateResume(it) } @@ -672,7 +798,7 @@ class HomeParentItemAdapterPreview( } toggleListHolder?.isGone = visible.isEmpty() } - } ?: debugException { "Expected findViewTreeLifecycleOwner" } + } } } } 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 4c4dd2d84..e42e774b5 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 @@ -1,23 +1,27 @@ 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.core.view.isGone -import androidx.fragment.app.Fragment import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo 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.ImageLoader.loadImage class HomeScrollAdapter( - fragment: Fragment -) : NoStateAdapter(fragment) { + val callback: ((View, Int, LoadResponse) -> Unit) +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.uniqueUrl == b.uniqueUrl && a.name == b.name +})) { var hasMoreItems: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { @@ -31,19 +35,26 @@ class HomeScrollAdapter( return ViewHolderState(binding) } + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is HomeScrollViewBinding -> { + clearImage(binding.homeScrollPreview) + } + + is HomeScrollViewTvBinding -> { + clearImage(binding.homeScrollPreview) + } + } + } + override fun onBindContent( holder: ViewHolderState, item: LoadResponse, position: Int, ) { val binding = holder.view - val itemView = holder.itemView - val isHorizontal = - binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val posterUrl = - if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl - ?: item.backgroundPosterUrl + val posterUrl = item.backgroundPosterUrl ?: item.posterUrl when (binding) { is HomeScrollViewBinding -> { @@ -53,10 +64,21 @@ class HomeScrollAdapter( isGone = item.tags.isNullOrEmpty() maxLines = 2 } - binding.homeScrollPreviewTitle.text = item.name + binding.homeScrollPreviewTitle.text = item.name.html() + + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = binding.homeScrollPreviewTitle, + logoView = binding.homePreviewLogo + ) } is HomeScrollViewTvBinding -> { + binding.homeScrollPreview.isFocusable = false + binding.homeScrollPreview.setOnClickListener { view -> + callback.invoke(view ?: return@setOnClickListener, position, item) + } binding.homeScrollPreview.loadImage(posterUrl) } } 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 fccf1bb2c..8d48f5a68 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 @@ -7,14 +7,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AcraApplication.Companion.context -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey 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 @@ -40,6 +40,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm 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.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds @@ -49,13 +50,12 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import java.util.EnumSet import java.util.concurrent.CopyOnWriteArrayList -import kotlin.collections.set class HomeViewModel : ViewModel() { companion object { @@ -67,11 +67,26 @@ class HomeViewModel : ViewModel() { } val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> - - val data = getKey( + val headerCache = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() - ) ?: return@mapNotNull null + ) + + val data = if (headerCache == null) { + // We store resume watching data in download header cache + // Because downloads automatically pruned outdated download headers we + // removed resume watching data. We should restore the data for affected users. + val oldData = getKey( + DOWNLOAD_HEADER_CACHE_BACKUP, + resume.parentId.toString() + ) ?: return@mapNotNull null + + // Restore data + setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData) + oldData + } else { + headerCache + } val watchPos = getViewPos(resume.episodeId) @@ -118,7 +133,7 @@ class HomeViewModel : ViewModel() { private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { - return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) + return APIRepository(apis.withLock { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = @@ -520,12 +535,12 @@ class HomeViewModel : ViewModel() { } 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) { + if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { loadAndCancel(noneApi) } else { _page.postValue(Resource.Loading()) if (preferredApiName != null) - _apiName.postValue(preferredApiName!!) + _apiName.postValue(preferredApiName) } } else { // if the api is found, then set it to it and save key 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 bfac72067..c5f8fa3d9 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 @@ -7,22 +7,16 @@ import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.TypedValue -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS import android.view.animation.AlphaAnimation -import android.widget.ImageView import android.widget.TextView -import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView import androidx.core.view.allViews import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView @@ -30,35 +24,33 @@ 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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey 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.utils.txt +import com.lagradost.cloudstream3.ui.BaseFragment 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.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs @@ -84,10 +76,10 @@ data class ProviderLibraryData( val apiName: String ) -class LibraryFragment : Fragment() { +class LibraryFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) +) { companion object { - - val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -98,35 +90,10 @@ 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() - } + override fun pickLayout(): Int? = + if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv override fun onSaveInstanceState(outState: Bundle) { binding?.viewpager?.currentItem?.let { currentItem -> @@ -135,48 +102,52 @@ class LibraryFragment : Fragment() { super.onSaveInstanceState(outState) } - private fun updateRandom() { + private fun updateRandomVisibility(binding: FragmentLibraryBinding) { + if (!toggleRandomButton) { + binding.libraryRandom.isGone = true + binding.libraryRandomButtonTv.isGone = true + return + } 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 - } + val hasItems = pages[position].items.isNotEmpty() + val isPhone = isLayout(PHONE) + + binding.libraryRandom.isVisible = isPhone && hasItems + binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = !isLayout(PHONE) + ) } @SuppressLint("ResourceType", "CutPasteId") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.searchStatusBarPadding) + override fun onBindingCreated( + binding: FragmentLibraryBinding, + savedInstanceState: Bundle? + ) { + binding.sortFab.setOnClickListener(sortChangeClickListener) + binding.librarySort.setOnClickListener(sortChangeClickListener) - binding?.sortFab?.setOnClickListener(sortChangeClickListener) - binding?.librarySort?.setOnClickListener(sortChangeClickListener) - - binding?.libraryRoot?.findViewById(androidx.appcompat.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) + binding.libraryRoot.findViewById(androidx.appcompat.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) + } } - } - - // 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 + val newText = binding.mainSearch.query.toString() libraryViewModel.sort(ListSorting.Query, newText) } - binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -192,11 +163,11 @@ class LibraryFragment : Fragment() { return true } - binding?.mainSearch?.removeCallbacks(searchCallback) + 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) + binding.mainSearch.postDelayed(searchCallback, 1000) return true } @@ -204,11 +175,12 @@ class LibraryFragment : Fragment() { libraryViewModel.reloadPages(false) - binding?.listSelector?.setOnClickListener { + binding.listSelector.setOnClickListener { val items = libraryViewModel.availableApiNames val currentItem = libraryViewModel.currentApiName.value - activity?.showBottomDialog(items, + activity?.showBottomDialog( + items, items.indexOf(currentItem), txt(R.string.select_library).asString(it.context), false, @@ -225,17 +197,9 @@ class LibraryFragment : Fragment() { 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) - } - } + ) + binding.libraryRandom.visibility = View.GONE + binding.libraryRandomButtonTv.visibility = View.GONE } /** @@ -246,14 +210,13 @@ 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, @@ -305,22 +268,21 @@ class LibraryFragment : Fragment() { } } - binding?.providerSelector?.setOnClickListener { + binding.providerSelector.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } - binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) + binding.viewpager.setPageTransformer(LibraryScrollTransformer()) - binding?.viewpager?.adapter = ViewpagerAdapter( - fragment = this, + binding.viewpager.adapter = ViewpagerAdapter( { isScrollingDown: Boolean -> if (isScrollingDown) { - binding?.sortFab?.shrink() - binding?.libraryRandom?.shrink() + binding.sortFab.shrink() + binding.libraryRandom.shrink() } else { - binding?.sortFab?.extend() - binding?.libraryRandom?.extend() + binding.sortFab.extend() + binding.libraryRandom.extend() } }) callback@{ searchClickCallback -> // To prevent future accidents @@ -353,15 +315,15 @@ class LibraryFragment : Fragment() { } } - binding?.apply { + binding.apply { viewpager.offscreenPageLimit = 2 viewpager.reduceDragSensitivity() searchBar.setExpanded(true) } val startLoading = Runnable { - binding?.apply { - gridview.numColumns = context?.getSpanCount() ?: 3 + binding.apply { + gridview.numColumns = root.context.getSpanCount() gridview.adapter = context?.let { LoadingPosterAdapter(it, 6 * 3) } libraryLoadingOverlay.isVisible = true @@ -371,7 +333,7 @@ class LibraryFragment : Fragment() { } val stopLoading = Runnable { - binding?.apply { + binding.apply { gridview.adapter = null libraryLoadingOverlay.isVisible = false libraryLoadingShimmer.stopShimmer() @@ -387,7 +349,7 @@ class LibraryFragment : Fragment() { val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } - binding?.apply { + binding.apply { emptyListTextview.isVisible = showNotice if (showNotice) { if (libraryViewModel.availableApiNames.size > 1) { @@ -415,10 +377,23 @@ class LibraryFragment : Fragment() { )*/ libraryViewModel.currentPage.value?.let { page -> - binding?.viewpager?.setCurrentItem(page, false) + binding.viewpager.setCurrentItem(page, false) + binding.searchBar.setExpanded(true) } - updateRandom() + // Set up random button click listener + if (toggleRandomButton) { + val randomClickListener = View.OnClickListener { + val position = libraryViewModel.currentPage.value ?: 0 + val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener + pages[position].items.randomOrNull()?.let { item -> + loadLibraryItem(syncIdName, item.syncId, item) + } + } + libraryRandom.setOnClickListener(randomClickListener) + libraryRandomButtonTv.setOnClickListener(randomClickListener) + } + updateRandomVisibility(binding) // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: @@ -459,21 +434,20 @@ class LibraryFragment : Fragment() { tab.view.nextFocusDownId = R.id.search_result_root tab.view.setOnClickListener { - val currentItem = - binding?.viewpager?.currentItem ?: return@setOnClickListener + val currentItem = binding.viewpager.currentItem val distance = abs(position - currentItem) hideViewpager(distance) } //Expand the appBar on tab focus tab.view.setOnFocusChangeListener { _, _ -> - binding?.searchBar?.setExpanded(true) + binding.searchBar.setExpanded(true) } }.attach() - binding?.libraryTabLayout?.addOnTabSelectedListener(object : + binding.libraryTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { - binding?.libraryTabLayout?.selectedTabPosition?.let { page -> + binding.libraryTabLayout.selectedTabPosition.let { page -> libraryViewModel.switchPage(page) } } @@ -498,11 +472,11 @@ class LibraryFragment : Fragment() { } observe(libraryViewModel.currentPage) { position -> - updateRandom() - val all = binding?.viewpager?.allViews?.toList() - ?.filterIsInstance() + updateRandomVisibility(binding) + val all = binding.viewpager.allViews.toList() + .filterIsInstance() - all?.forEach { view -> + all.forEach { view -> view.isVisible = view.tag == position view.isFocusable = view.tag == position @@ -512,14 +486,6 @@ class LibraryFragment : Fragment() { view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS } } - - /*binding?.viewpager?.registerOnPageChangeCallback(object : - ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - - super.onPageSelected(position) - } - })*/ } private fun loadLibraryItem( @@ -578,10 +544,10 @@ class LibraryFragment : Fragment() { } - @SuppressLint("NotifyDataSetChanged") override fun onConfigurationChanged(newConfig: Configuration) { - binding?.viewpager?.adapter?.notifyDataSetChanged() super.onConfigurationChanged(newConfig) + val adapter = binding?.viewpager?.adapter ?: return + adapter.notifyItemRangeChanged(0, adapter.itemCount) } private val sortChangeClickListener = View.OnClickListener { view -> @@ -589,7 +555,8 @@ class LibraryFragment : Fragment() { txt(it.stringRes).asString(view.context) } - activity?.showBottomDialog(methods, + activity?.showBottomDialog( + methods, libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), txt(R.string.sort_by).asString(view.context), false, 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 f7713e9b2..38f7fcf9d 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 @@ -4,8 +4,8 @@ import androidx.annotation.StringRes import androidx.lifecycle.LiveData 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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource 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 a2570e684..066cf468d 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 @@ -1,31 +1,34 @@ package com.lagradost.cloudstream3.ui.library -import android.graphics.Color import android.view.LayoutInflater import android.view.ViewGroup import android.widget.FrameLayout -import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState 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.UIHelper.toPx import kotlin.math.roundToInt - class PageAdapter( - override val items: MutableList, private val resView: AutofitRecyclerView, val clickCallback: (SearchClickCallback) -> Unit ) : - AppContextUtils.DiffAdapter(items) { + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name && a.url == b.url + } + })) { + private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return LibraryItemViewHolder( + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( SearchResultGridExpandedBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -34,86 +37,45 @@ class PageAdapter( ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is LibraryItemViewHolder -> { - holder.bind(items[position], position) + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> { + clearImage(binding.imageView) } } } - private fun isDark(color: Int): Boolean { - return ColorUtils.calculateLuminance(color) < 0.5 - } + override fun onBindContent( + holder: ViewHolderState, + item: SyncAPI.LibraryItem, + position: Int + ) { + val binding = holder.view as? SearchResultGridExpandedBinding ?: return - fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int { - return if (isDark(color)) { - ColorUtils.blendARGB(color, Color.WHITE, ratio) - } else { - ColorUtils.blendARGB(color, Color.BLACK, ratio) + /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ + SearchResultBuilder.bind( + this@PageAdapter.clickCallback, + item, + position, + holder.itemView, + ) + + // See searchAdaptor for this, it basically fixes the height + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) { + binding.imageView.layoutParams = params } - } - inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) : - RecyclerView.ViewHolder(binding.root) { - - private val compactView = false//itemView.context.getGridIsCompact() - private val coverHeight: Int = - if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() - - fun bind(item: SyncAPI.LibraryItem, position: Int) { - /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ - - SearchResultBuilder.bind( - this@PageAdapter.clickCallback, - item, - position, - itemView, - /*colorCallback = { palette -> - AcraApplication.context?.let { ctx -> - val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg) - var bg = palette.getDarkVibrantColor(defColor) - if (bg == defColor) { - bg = palette.getDarkMutedColor(defColor) - } - if (bg == defColor) { - bg = palette.getVibrantColor(defColor) - } - - val fg = - getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor)) - binding.textRating.apply { - setTextColor(ColorStateList.valueOf(fg)) - } - binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg) - binding.textRating.backgroundTintList = ColorStateList.valueOf(bg) - binding.watchProgress.apply { - progressTintList = ColorStateList.valueOf(fg) - progressBackgroundTintList = ColorStateList.valueOf(bg) - } - } - } - */ - ) - - // See searchAdaptor for this, it basically fixes the height - if (!compactView) { - binding.imageView.apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - } - } - - val showProgress = item.episodesCompleted != null && item.episodesTotal != null - binding.watchProgress.isVisible = showProgress - if (showProgress) { - binding.watchProgress.max = item.episodesTotal!! - binding.watchProgress.progress = item.episodesCompleted!! - } - - binding.imageText.text = item.name + val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null + binding.watchProgress.isVisible = showProgress + if (showProgress) { + binding.watchProgress.max = item.episodesTotal + binding.watchProgress.progress = item.episodesCompleted } + + binding.imageText.text = item.name } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index 0110187f6..68b6eb273 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 @@ -40,19 +40,19 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) } class ViewpagerAdapter( - fragment: Fragment, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : BaseAdapter(fragment, +) : BaseAdapter( id = "ViewpagerAdapter".hashCode(), diffCallback = BaseDiffCallback( - itemSame = { a, b -> - a.title == b.title - }, - contentSame = { a, b -> - a.items == b.items && a.title == b.title - } -)) { + 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) @@ -66,7 +66,8 @@ class ViewpagerAdapter( ) { val binding = holder.view if (binding !is LibraryViewpagerPageBinding) return - (binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items) + (binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) + binding.pageRecyclerview.scrollToPosition(0) } override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { @@ -75,21 +76,21 @@ class ViewpagerAdapter( binding.pageRecyclerview.tag = position binding.pageRecyclerview.apply { - spanCount = - binding.root.context.getSpanCount() ?: 3 + spanCount = binding.root.context.getSpanCount() if (adapter == null) { // || rebind // Only add the items after it has been attached since the items rely on ItemWidth // Which is only determined after the recyclerview is attached. // If this fails then item height becomes 0 when there is only one item doOnAttach { adapter = PageAdapter( - item.items.toMutableList(), this, clickCallback - ) + ).apply { + submitList(item.items) + } } } else { - (adapter as? PageAdapter)?.updateList(item.items) + (adapter as? PageAdapter)?.submitList(item.items) // scrollToPosition(0) } @@ -100,7 +101,7 @@ class ViewpagerAdapter( //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 { + ?.apply { if (diff <= 0) setExpanded(true) else 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 5ba4c6a1d..e5a460b9a 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,61 +1,16 @@ 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.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.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.OptIn 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.common.util.UnstableApi import androidx.media3.session.MediaSession -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import androidx.media3.ui.TimeBar -import androidx.preference.PreferenceManager -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.github.rubensousa.previewseekbar.PreviewBar -import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar -import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode -import com.lagradost.cloudstream3.CommonActivity.isInPIPMode -import com.lagradost.cloudstream3.CommonActivity.keyEventListener -import com.lagradost.cloudstream3.CommonActivity.playerEventListener -import com.lagradost.cloudstream3.CommonActivity.screenWidth -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.ErrorLoadingException +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -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.EpisodeSkip -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI -import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import java.net.SocketTimeoutException +import com.lagradost.cloudstream3.ui.BaseFragment enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -75,669 +30,132 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 // when the player should sync the progress of "watched", TODO MAKE SETTING const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 -abstract class AbstractPlayerFragment( - var player: IPlayer = CS3IPlayer() -) : Fragment() { - var resizeMode: Int = 0 - var subView: SubtitleView? = null - var isBuffering = true - protected open var hasPipModeSupport = true +@OptIn(UnstableApi::class) +abstract class AbstractPlayerFragment( + bindingCreator: BindingCreator +) : BaseFragment(bindingCreator), PlayerView.Callbacks { - var playerPausePlayHolderHolder: FrameLayout? = null - var playerPausePlay: ImageView? = null - var playerBuffering: ProgressBar? = null - var playerView: PlayerView? = null - var piphide: FrameLayout? = null - var subtitleHolder: FrameLayout? = null + // Stored pre-initialization so subclasses can set them before onBindingCreated. + private var _player: IPlayer = CS3IPlayer() - @LayoutRes - protected open var layout: Int = R.layout.fragment_player + /** The shared [PlayerView] host that owns all player state and view references. */ + protected var playerHostView: PlayerView? = null - open fun nextEpisode() { - throw NotImplementedError() - } - - open fun prevEpisode() { - throw NotImplementedError() - } - - open fun playerPositionChanged(position: Long, duration: Long) { - throw NotImplementedError() - } - - open fun playerStatusChanged() {} - - open fun playerDimensionsLoaded(width: Int, height: Int) { - throw NotImplementedError() - } - - open fun subtitlesChanged() { - throw NotImplementedError() - } - - open fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } - - open fun onTracksInfoChanged() { - throw NotImplementedError() - } - - open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { - - } - - open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { - - } - - open fun exitedPipMode() { - throw NotImplementedError() - } - - private fun keepScreenOn(on: Boolean) { - if (on) { - activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - private fun updateIsPlaying( - wasPlaying: CSPlayerLoading, - isPlaying: CSPlayerLoading - ) { - val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying - val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying - - keepScreenOn(!isPausedRightNow) - - isBuffering = CSPlayerLoading.IsBuffering == isPlaying - if (isBuffering) { - playerPausePlayHolderHolder?.isVisible = false - playerBuffering?.isVisible = true - } else { - playerPausePlayHolderHolder?.isVisible = true - playerBuffering?.isVisible = false - - if (wasPlaying != isPlaying) { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) - val drawable = playerPausePlay?.drawable - - var startedAnimation = false - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - if (drawable is AnimatedImageDrawable) { - drawable.start() - startedAnimation = true - } - } - - if (drawable is AnimatedVectorDrawable) { - drawable.start() - startedAnimation = true - } - - if (drawable is AnimatedVectorDrawableCompat) { - drawable.start() - startedAnimation = true - } - - // somehow the phone is wacked - if (!startedAnimation) { - playerPausePlay?.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) - } + var player: IPlayer + get() = playerHostView?.player ?: _player + set(value) { + _player = value + playerHostView?.player = value } - canEnterPipMode = isPlayingRightNow && hasPipModeSupport - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.let { act -> - PlayerPipHelper.updatePIPModeActions( - act, - isPlayingRightNow, - player.getAspectRatio() - ) - } - } + val subView: SubtitleView? get() = playerHostView?.subView + val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay + + /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ + val playerView: androidx.media3.ui.PlayerView? + get() = playerHostView?.exoPlayerView + + var currentPlayerStatus: CSPlayerLoading + get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering + set(value) { playerHostView?.currentPlayerStatus = value } + + protected var mMediaSession: MediaSession? + get() = playerHostView?.mMediaSession + set(value) { playerHostView?.mMediaSession = value } + + // No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as + // open so subclasses can override only what they need. The ones below throw + // to make it obvious when an implementation is missing. + + override fun nextEpisode() { + throw NotImplementedError() + } + + override fun prevEpisode() { + throw NotImplementedError() + } + + override fun playerPositionChanged(position: Long, duration: Long) { + throw NotImplementedError() + } + + override fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() + } + + override fun subtitlesChanged() { + throw NotImplementedError() + } + + override fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() + } + + override fun onTracksInfoChanged() { + throw NotImplementedError() + } + + override fun exitedPipMode() { + throw NotImplementedError() + } + + override fun hasNextMirror(): Boolean { + throw NotImplementedError() + } + + override fun nextMirror() { + throw NotImplementedError() + } + + /** Delegates to [PlayerView.playerError] by default; override to customize. */ + override fun playerError(exception: Throwable) { + playerHostView?.playerError(exception) + } + + /** Player fragments don't need system-bar padding adjustment by default. */ + override fun fixLayout(view: View) = Unit + + override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = _player + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerHostView?.initialize() } - 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 - pipReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent, - ) { - if (ACTION_MEDIA_CONTROL != intent.action) { - return - } - player.handleEvent( - CSPlayerEvent.entries[intent.getIntExtra( - EXTRA_CONTROL_TYPE, - 0 - )], source = PlayerEventSource.UI - ) - } - } - val filter = IntentFilter() - filter.addAction(ACTION_MEDIA_CONTROL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) - } else activity?.registerReceiver(pipReceiver, filter) - val isPlaying = player.getIsPlaying() - val isPlayingValue = - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(isPlayingValue, isPlayingValue) - } else { - // Restore the full-screen UI. - piphide?.isVisible = true - exitedPipMode() - pipReceiver?.let { - // Prevents java.lang.IllegalArgumentException: Receiver not registered - safe { - activity?.unregisterReceiver(it) - } - } - activity?.hideSystemUI() - this.view?.let { UIHelper.hideKeyboard(it) } - } - } catch (e: Exception) { - logError(e) - } - } - - open fun hasNextMirror(): Boolean { - throw NotImplementedError() - } - - open fun nextMirror() { - throw NotImplementedError() - } - - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) - } - } - - open fun playerError(exception: Throwable) { - fun showToast(message: String, gotoNext: Boolean = false) { - if (gotoNext && hasNextMirror()) { - showToast( - message, - Toast.LENGTH_SHORT - ) - nextMirror() - } else { - showToast( - context?.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - activity?.popCurrentPage() - } - } - - val ctx = context ?: return - when (exception) { - is PlaybackException -> { - val msg = exception.message ?: "" - val errorName = exception.errorCodeName - when (val code = exception.errorCode) { - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, - PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, - PlaybackException.ERROR_CODE_IO_NO_PERMISSION, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> { - showToast( - "${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg", - 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_NETWORK_CONNECTION_TIMEOUT, - PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { - showToast( - "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, - PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, - PlaybackException.ERROR_CODE_DECODING_FAILED, - 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 - ) - } - - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { - showToast( - "${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { - showToast( - "${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - else -> { - showToast( - "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", - gotoNext = false - ) - } - } - } - - is InvalidFileException -> { - showToast( - "${ctx.getString(R.string.source_error)}\n${exception.message}", - gotoNext = true - ) - } - - is SocketTimeoutException -> { - /** - * Ensures this is run on the UI thread to prevent issues - * caused by SocketTimeoutException in torrents. Running - * on another thread can break player interactions or - * prevent switching to the next source. - */ - activity?.runOnUiThread { - showToast( - "${ctx.getString(R.string.remote_error)}\n${exception.message}", - gotoNext = true - ) - } - } - - is ErrorLoadingException -> { - exception.message?.let { - showToast( - it, - gotoNext = true - ) - } ?: showToast( - exception.toString(), - gotoNext = true - ) - } - - else -> { - exception.message?.let { - showToast( - it, - gotoNext = false - ) - } ?: showToast( - exception.toString(), - gotoNext = false - ) - } - } - } - - private fun onSubStyleChanged(style: SaveCaptionStyle) { - player.updateSubtitleStyle(style) - // Forcefully update the subtitle encoding in case the edge size is changed - player.seekTime(-1) - } - - - @SuppressLint("UnsafeOptInUsageError") - open 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(System.currentTimeMillis().toString()) - .build() - } - - // Necessary for multiple combined videos - @Suppress("DEPRECATION") - playerView?.setShowMultiWindowTimeBar(true) - playerView?.player = player - playerView?.performClick() - } - } - - protected var mMediaSession: MediaSession? = null - - // this can be used in the future for players other than exoplayer - //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { - // override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - // val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent? - // if (keyEvent != null) { - // if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP - // val consumed = when (keyEvent.keyCode) { - // KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause() - // KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay() - // KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop() - // KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext() - // else -> false - // } - // if (consumed) return true - // } - // } - // - // return super.onMediaButtonEvent(mediaButtonEvent) - // } - //} - - open fun onDownload(event: DownloadEvent) = Unit - - /** 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) { - // we don't want to spam DownloadEvent - if (event !is DownloadEvent) { - Log.i(TAG, "Handle event: $event") - } - when (event) { - is DownloadEvent -> { - onDownload(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") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - resizeMode = DataStoreHelper.resizeMode - resize(resizeMode, false) - - player.releaseCallbacks() - player.initCallbacks( - eventHandler = ::mainCallback, - requestedListeningPercentages = listOf( - SKIP_OP_VIDEO_PERCENTAGE, - PRELOAD_NEXT_EPISODE_PERCENTAGE, - NEXT_WATCH_EPISODE_PERCENTAGE, - UPDATE_SYNC_PROGRESS_PERCENTAGE, - ), - ) - - val player = player - 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 - ) - - // No clashing UI - if (hasPreview) { - subView?.isVisible = false - } - } - - override fun onScrubMove( - previewBar: PreviewBar?, - progress: Int, - fromUser: Boolean - ) { - } - - override fun onScrubStop(previewBar: PreviewBar?) { - if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) - // Delay to prevent the small flicker of subtitle before seeking - subView?.postDelayed({ - // If we are not scrubbing then show subtitles again - if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { - subView?.isVisible = true - } - }, 200) - } - }) - 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(androidx.media3.ui.R.id.exo_subtitles) - player.initSubtitles(subView, subtitleHolder, CustomDecoder.style) - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth) - - /*previewImageView?.doOnLayout { - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams( - it.measuredWidth, - it.measuredHeight - ) - }*/ - /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player - * and once by the UI even if it should only be registered once by the UI */ - playerView?.findViewById(R.id.exo_progress) - ?.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit - override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - if (canceled) return - val playerDuration = player.getDuration() ?: return - val playerPosition = player.getPosition() ?: return - mainCallback( - PositionEvent( - source = PlayerEventSource.UI, - durationMs = playerDuration, - fromMs = playerPosition, - toMs = position - ) - ) - } - }) - - SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged - - try { - context?.let { ctx -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences( - ctx - ) - - val currentPrefCacheSize = - settingsManager.getInt(getString(R.string.video_buffer_size_key), 0) - val currentPrefDiskSize = - settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0) - val currentPrefBufferSec = - settingsManager.getInt(getString(R.string.video_buffer_length_key), 0) - - player.cacheSize = currentPrefCacheSize * 1024L * 1024L - player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L - player.videoBufferMs = currentPrefBufferSec * 1000L - } - } catch (e: Exception) { - logError(e) - } - } - - /*context?.let { ctx -> - player.loadPlayer( - ctx, - false, - ExtractorLink( - "idk", - "bunny", - "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", - "", - Qualities.P720.value, - false - ), - ) - }*/ + playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } override fun onDestroy() { - playerEventListener = null - keyEventListener = null - canEnterPipMode = false - mMediaSession?.release() - mMediaSession = null - playerView?.player = null - SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged - - keepScreenOn(false) + playerHostView?.release() super.onDestroy() } - fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.entries.size - resize(resizeMode, true) - } - - fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.entries[resize], showToast) - } - - @SuppressLint("UnsafeOptInUsageError") - fun resize(resize: PlayerResize, showToast: Boolean) { - DataStoreHelper.resizeMode = resize.ordinal - val type = when (resize) { - PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL - PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT - PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - playerView?.resizeMode = type - - if (showToast) - showToast(resize.nameRes, Toast.LENGTH_SHORT) + override fun onPause() { + playerHostView?.releaseKeyEventListener() + super.onPause() } override fun onStop() { - player.onStop() + playerHostView?.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - player.onResume(ctx) + playerHostView?.onResume(ctx) } - super.onResume() } - override fun onCreateView( - inflater: LayoutInflater, - 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 + fun nextResize() { + playerHostView?.nextResize() } -} \ No newline at end of file + + open fun resize(resize: PlayerResize, showToast: Boolean) { + playerHostView?.resize(resize, showToast) + } +} 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 ad216eee8..d7e10c814 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 @@ -12,9 +12,11 @@ import android.os.Looper import android.util.Log import android.util.Rational import android.widget.FrameLayout +import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_TEXT @@ -28,6 +30,7 @@ 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.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource @@ -37,33 +40,42 @@ 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.cronet.CronetDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DecoderCounters +import androidx.media3.exoplayer.DecoderReuseEvaluation +import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer.STATE_ENABLED import androidx.media3.exoplayer.Renderer.STATE_STARTED import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.FrameworkMediaDrm import androidx.media3.exoplayer.drm.HttpMediaDrmCallback import androidx.media3.exoplayer.drm.LocalMediaDrmCallback +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector +import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.AudioFile +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit @@ -75,31 +87,38 @@ import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment -import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.player.live.LiveHelper +import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET +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.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread 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.CLEARKEY_UUID -import com.lagradost.cloudstream3.utils.WIDEVINE_UUID -import com.lagradost.cloudstream3.utils.PLAYREADY_UUID import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage -import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory +import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import kotlinx.coroutines.delay +import okhttp3.Interceptor +import org.chromium.net.CronetEngine import java.io.File +import java.security.SecureRandom import java.util.UUID +import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession +import kotlin.uuid.toJavaUuid const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" @@ -116,6 +135,7 @@ const val toleranceAfterUs = 300_000L @OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { + private var playerListener: Player.Listener? = null private var isPlaying = false private var exoPlayer: ExoPlayer? = null set(value) { @@ -172,7 +192,6 @@ class CS3IPlayer : IPlayer { val kty: String? = null, val licenseUrl: String? = null, val keyRequestParameters: HashMap, - val headers: Map = emptyMap(), ) override fun getDuration(): Long? = exoPlayer?.duration @@ -189,45 +208,43 @@ class CS3IPlayer : IPlayer { private var requestedListeningPercentages: List? = null private var eventHandler: ((PlayerEvent) -> Unit)? = null - private val mainHandler = Handler(Looper.getMainLooper()) + @AnyThread fun event(event: PlayerEvent) { - // Ensure that all work is done on the main looper, aka main thread - if (Looper.myLooper() == mainHandler.looper) { + // Ensure that all work is done on the main thread. + if (Looper.getMainLooper().isCurrentThread) { + eventHandler?.invoke(event) + } else runOnMainThread { eventHandler?.invoke(event) - } else { - mainHandler.post { - eventHandler?.invoke(event) - } } } + /** + * As initCallbacks and releaseCallbacks must always be done, + * we use this to say that the player is in use. + * */ + @Volatile + var isPlayerActive: Boolean = false + override fun releaseCallbacks() { eventHandler = null + if (isPlayerActive) { + isPlayerActive = false + activePlayers -= 1 + releaseCronetEngine() + } } + @AnyThread override fun initCallbacks( - eventHandler: ((PlayerEvent) -> Unit), + @MainThread eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, ) { this.requestedListeningPercentages = requestedListeningPercentages this.eventHandler = eventHandler - } - - // I know, this is not a perfect solution, however it works for fixing subs - private fun reloadSubs() { - exoPlayer?.applicationLooper?.let { - try { - Handler(it).post { - try { - seekTime(1L, source = PlayerEventSource.Player) - } catch (e: Exception) { - logError(e) - } - } - } catch (e: Exception) { - logError(e) - } + if (!isPlayerActive) { + isPlayerActive = true + activePlayers += 1 } } @@ -244,6 +261,10 @@ class CS3IPlayer : IPlayer { } override fun hasPreview(): Boolean { + // No previews on livestreams because the previews get outdated + if (exoPlayer?.isCurrentMediaItemDynamic == true) { + return false + } return imageGenerator.hasPreview() } @@ -357,44 +378,47 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { preferredAudioTrackLanguage = trackLanguage - - if (id != null) { - val audioTrack = - exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO } - ?.getTrack(id) - - if (audioTrack != null) { - exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters - ?.buildUpon() - ?.setOverrideForType( - TrackSelectionOverride( - audioTrack.first, - audioTrack.second + id?.let { trackId -> + val trackFormatIndex = formatIndex ?: 0 + exoPlayer?.currentTracks?.groups + ?.filter { it.type == TRACK_TYPE_AUDIO } + ?.find { group -> + group.getFormats().any { (format, _) -> + format.id == trackId + } + } + ?.let { group -> + exoPlayer?.trackSelectionParameters + ?.buildUpon() + ?.setOverrideForType( + TrackSelectionOverride( + group.mediaTrackGroup, + trackFormatIndex + ) ) - ) - ?.build() - ?: return - return - } + ?.build() + } + ?.let { newParams -> + exoPlayer?.trackSelectionParameters = newParams + return + } } - + // Fallback to language-based selection exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setPreferredAudioLanguage(trackLanguage) - ?.build() - ?: return + ?.build() ?: return } - /** * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.map { + return this.flatMap { it.getFormats() - }.flatten() + } } private fun Tracks.Group.getFormats(): List> { @@ -405,11 +429,14 @@ class CS3IPlayer : IPlayer { } } - private fun Format.toAudioTrack(): AudioTrack { + private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack { return AudioTrack( - this.id?.stripTrackId(), + this.id, this.label, - this.language + this.language, + this.sampleMimeType, + this.channelCount, + formatIndex ?: 0, ) } @@ -418,7 +445,7 @@ class CS3IPlayer : IPlayer { this.id?.stripTrackId(), this.label, this.language, - this.sampleMimeType + this.sampleMimeType, ) } @@ -429,27 +456,35 @@ class CS3IPlayer : IPlayer { this.language, this.width, this.height, + this.sampleMimeType ) } override fun getVideoTracks(): CurrentTracks { - val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() - val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } + val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() + val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } - val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() - .map { it.first.toAudioTrack() } - - val textTracks = allTracks.filter { it.type == TRACK_TYPE_TEXT }.getFormats() + var currentAudioTrack: AudioTrack? = null + val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } + .flatMap { group -> + group.getFormats().map { (format, formatIndex) -> + val audioTrack = format.toAudioTrack(formatIndex) + if (group.isTrackSelected(formatIndex)) { + currentAudioTrack = audioTrack + } + audioTrack + } + } + val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT } + .getFormats() .map { it.first.toSubtitleTrack() } - val currentTextTracks = textTracks.filter { track -> playerSelectedSubtitleTracks.any { it.second && it.first == track.id } } - return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), - exoPlayer?.audioFormat?.toAudioTrack(), + currentAudioTrack, currentTextTracks, videoTracks, audioTracks, @@ -463,60 +498,43 @@ class CS3IPlayer : IPlayer { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle + val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false + // Disable subtitles if null + if (subtitle == null) { + trackSelector.setParameters( + trackSelector.buildUponParameters() + .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) + .clearOverridesOfType(TRACK_TYPE_TEXT) + ) + return false + } + // Handle subtitle based on status + when (subtitleHelper.subtitleStatus(subtitle)) { + SubtitleStatus.REQUIRES_RELOAD -> { + Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") + return true + } - fun getTextTrack(id: String) = - exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT } - ?.getTrack(id) - - return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector -> - if (subtitle == null) { - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) - .clearOverridesOfType(TRACK_TYPE_TEXT) - ) - } else { - when (subtitleHelper.subtitleStatus(subtitle)) { - SubtitleStatus.REQUIRES_RELOAD -> { - Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") - return@let true - } - - SubtitleStatus.IS_ACTIVE -> { - Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") + SubtitleStatus.NOT_FOUND -> { + Log.i(TAG, "setPreferredSubtitles NOT_FOUND") + return true + } + SubtitleStatus.IS_ACTIVE -> { + Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") + exoPlayer?.currentTracks?.groups + ?.filter { it.type == TRACK_TYPE_TEXT } + ?.getTrack(subtitle.getId()) + ?.let { (trackGroup, trackIndex) -> trackSelector.setParameters( trackSelector.buildUponParameters() - .apply { - val track = getTextTrack(subtitle.getId()) - if (track != null) { - setTrackTypeDisabled(TRACK_TYPE_TEXT, false) - setOverrideForType( - TrackSelectionOverride( - track.first, - track.second - ) - ) - } - } + .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) + .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) ) - - // ugliest code I have written, it seeks 1ms to *update* the subtitles - //exoPlayer?.applicationLooper?.let { - // Handler(it).postDelayed({ - // seekTime(1L) - // }, 1) - //} } - - SubtitleStatus.NOT_FOUND -> { - Log.i(TAG, "setPreferredSubtitles NOT_FOUND") - return@let true - } - } + return false } - return false - } ?: false + } } private var currentSubtitleOffset: Long = 0 @@ -525,10 +543,10 @@ class CS3IPlayer : IPlayer { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { - exoPlayer?.currentPosition?.let { pos -> + exoPlayer?.currentPosition?.also { pos -> // This seems to properly refresh all subtitles // It needs to be done as all subtitle cues with timings are pre-processed - currentTextRenderer?.resetPosition(pos) + currentTextRenderer?.resetPosition(pos, false) } } } @@ -590,7 +608,10 @@ class CS3IPlayer : IPlayer { // No documented exception, but just to be extra safe logError(t) } - + playerListener?.let { + removeListener(it) + playerListener = null + } stop() release() } @@ -637,6 +658,62 @@ class CS3IPlayer : IPlayer { } companion object { + private const val CRONET_TIMEOUT_MS = 15_000 + + /** + * Single shared engine, to minimize the overhead of maintaining many as: + * 1. Cpu time/Startup time + * 2. Mem consumption/GC + * 3. Disk usage, as we simply use the same folder + * */ + private var cronetEngine: CronetEngine? = null + + /** + * How many active sessions we have. + * + * However in reality it should never go negative or be more than 1, + * but this makes more sense architecturally. + * */ + @Volatile + private var activePlayers = 0 + + /** Unique monotonically increasing id to keep track of the last release call */ + @Volatile + private var cronetReleasedId = 0 + + fun releaseCronetEngine() { + if (cronetEngine == null) return + + // Delayed release, as we do not want to restart it when opening trailers ect + val id = ++cronetReleasedId + val posted = Handler(Looper.getMainLooper()).postDelayed({ + // This might get dropped, but that should be very rare + // and should not affect it. + releaseCronetEngineInstantly(id) + }, 60_000) // 1min timeout before release + + // If not posted, then run instantly + if (!posted) { + releaseCronetEngineInstantly(id) + } + } + + private fun releaseCronetEngineInstantly(id: Int) { + // We should release if and only if this was the last call, and + // there is no active players + if (activePlayers == 0 && id == cronetReleasedId) { + try { + cronetEngine?.shutdown() + } catch (t: Throwable) { + logError(t) + } finally { + Log.d(TAG, "CronetEngine shutdown") + // Even if it fails to shutdown, the GC should take care of it + cronetEngine = null + } + } + } + /** * Setting this variable is permanent across app sessions. **/ @@ -655,42 +732,98 @@ class CS3IPlayer : IPlayer { } private var simpleCache: SimpleCache? = null - private fun createOnlineSource(headers: Map): HttpDataSource.Factory { - val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) - return source.apply { - setDefaultRequestProperties(headers) + + /// Create a small factory for small things, no cache, no cronet + private fun createOnlineSource( + headers: Map?, + interceptor: Interceptor? + ): HttpDataSource.Factory { + val client = if (interceptor == null) { + app.baseClient + } else { + app.baseClient.newBuilder() + .addInterceptor(interceptor) + .build() + } + val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) + + if (!headers.isNullOrEmpty()) { + source.setDefaultRequestProperties(headers) + } + return source + } + + fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { + // Fast case, no need to recreate it + cronetEngine?.let { + return it + } + + // https://gist.github.com/ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 + return try { + val cacheDirectory = File(context.cacheDir, "CronetEngine") + cacheDirectory.deleteRecursively() + if (!cacheDirectory.exists()) { + cacheDirectory.mkdirs() + } + CronetEngine.Builder(context) + .enableBrotli(true) + .enableHttp2(true) + .enableQuic(true) + .setStoragePath(cacheDirectory.absolutePath) + .setLibraryLoader(null) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, diskCacheSize) + .build().also { buildEngine -> + Log.d( + TAG, + "Created CronetEngine with cache at ${cacheDirectory.absolutePath}" + ) + cronetEngine = buildEngine + } + } catch (t: Throwable) { + logError(t) + // Something went wrong, so we use the backup okhttp + null } } - private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { - val provider = getApiFromNameNull(link.source) - val interceptor = provider?.getVideoInterceptor(link) + private fun createVideoSource( + link: ExtractorLink, + engine: CronetEngine?, + interceptor: Interceptor?, + ): HttpDataSource.Factory { val userAgent = link.headers.entries.find { it.key.equals("User-Agent", ignoreCase = true) - }?.value + }?.value ?: USER_AGENT val source = if (interceptor == null) { - DefaultHttpDataSource.Factory() //TODO USE app.baseClient - .setUserAgent(userAgent ?: USER_AGENT) - .setAllowCrossProtocolRedirects(true) //https://stackoverflow.com/questions/69040127/error-code-io-bad-http-status-exoplayer-android + if (engine == null) { + Log.d(TAG, "Using DefaultHttpDataSource for $link") + OkHttpDataSource.Factory(app.baseClient).setUserAgent(userAgent) + } else { + Log.d(TAG, "Using CronetDataSource for $link") + CronetDataSource.Factory(engine, Executors.newSingleThreadExecutor()) + .setUserAgent(userAgent) + .setConnectionTimeoutMs(CRONET_TIMEOUT_MS) + .setReadTimeoutMs(CRONET_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setHandleSetCookieRequests(true) + } } else { + Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() - OkHttpDataSource.Factory(client).setUserAgent(userAgent ?: USER_AGENT) + OkHttpDataSource.Factory(client).setUserAgent(userAgent) } // 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( - "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 + + // These are extra headers the browser like to insert, not sure if we want to include them + // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. + val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -750,10 +883,10 @@ class CS3IPlayer : IPlayer { private var currentTextRenderer: TextRenderer? = null } - private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { + private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { - if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { + if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) { return lastTimeStamp } } @@ -812,6 +945,22 @@ class CS3IPlayer : IPlayer { when (event) { CSPlayerEvent.Play -> { event(PlayEvent(source)) + // If the player was stopped (e.g. notification dismissed) it lands in + // STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and + // then resume to the current position once we are in STATE_READY again. + if (playbackState == Player.STATE_IDLE) { + val seekPosition = currentPosition + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, seekPosition) + exoPlayer?.removeListener(this) + } + }) + prepare() + } play() } @@ -865,7 +1014,7 @@ class CS3IPlayer : IPlayer { if (lastTimeStamp.skipToNextEpisode) { handleEvent(CSPlayerEvent.NextEpisode, source) } else { - seekTo(lastTimeStamp.endMs + 1L) + seekTo(lastTimeStamp.timestamp.endMs + 1L) } event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } @@ -921,39 +1070,57 @@ class CS3IPlayer : IPlayer { subtitleOffset: Long, cacheSize: Long, videoBufferMs: Long, + onlineSource: HttpDataSource.Factory? = null, playWhenReady: Boolean = true, - cacheFactory: CacheDataSource.Factory? = null, trackSelector: TrackSelector? = null, /** * Sets the m3u8 preferred video quality, will not force stop anything with higher quality. * Does not work if trackSelector is defined. **/ - maxVideoHeight: Int? = null + maxVideoHeight: Int? = null, + /** External audio tracks to merge with the video */ + audioSources: List = emptyList() ): ExoPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) - .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> + .setMediaSourceFactory( + DefaultMediaSourceFactory(context).setLiveTargetOffsetMs( + PREFERRED_LIVE_OFFSET + ) + ) + .setLivePlaybackSpeedControl( + DefaultLivePlaybackSpeedControl.Builder() + .setFallbackMaxPlaybackSpeed(1.03f) + .setFallbackMinPlaybackSpeed(0.97f) + .build() + ) + .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val current = settingsManager.getInt( context.getString(R.string.software_decoding_key), -1 ) - val softwareDecoding = when (current) { - 0 -> true // yes - 1 -> false // no + val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) { + 0 -> true to false // HW+SW, aka on but prefer hw + 2 -> true to true // SW+HW, aka on but prefer sw + 1 -> false to false // HW, aka off // -1 = automatic - else -> { - // we do not want tv to have software decoding, because of crashes - !isLayout(TV) - } + // We do not want tv to have software decoding, because of crashes + else -> isLayout(PHONE or EMULATOR) to false } - val factory = if (softwareDecoding) { - NextRenderersFactory(context).apply { + val factory = if (isSoftwareDecodingEnabled) { + FixedNextRenderersFactory(context).apply { setEnableDecoderFallback(true) - setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + setExtensionRendererMode( + if (isSoftwareDecodingPreferred) + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + else + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + ) } } else { + // no nextlib = EXTENSION_RENDERER_MODE_OFF DefaultRenderersFactory(context) } @@ -961,7 +1128,8 @@ class CS3IPlayer : IPlayer { // Custom TextOutput to apply cue styling and rules to all subtitles val customTextOutput = TextOutput { cue -> // Do not remove filterNotNull as Java typesystem is fucked - val (bitmapCues, textCues) = cue.cues.filterNotNull().partition { it.bitmap != null } + val (bitmapCues, textCues) = cue.cues.toList() + .partition { it.bitmap != null } val styledBitmapCues = bitmapCues.map { bitmapCue -> bitmapCue @@ -971,16 +1139,38 @@ class CS3IPlayer : IPlayer { .build() } + // Reuse memory, to avoid many allocations + val set = HashSet() + val buffer = StringBuilder() + // Move cues into one single one // This is to prevent text overlap in vtt (and potentially other) subtitle files val styledTextCues = textCues.groupBy { // Groups cues which share the same positon it.lineAnchor to it.position.times(1000.0f).toInt() }.mapNotNull { (_, entries) -> - val combinedCueText = entries.joinToString("\n") { - it.text?.toString() ?: "" + set.clear() + buffer.clear() + var count = 0 + for (x in entries) { + // Only allow non null text, otherwise we might have "a\n\nb" + val text = x.text ?: continue + + // Prevent duplicate entries, this often happens when the subtitle file + // uses multiple text lines as outlines. Most commonly found in fansubs + // with fancy subtitle styling. + if (!set.add(text)) { + continue + } + if (++count > 1) buffer.append('\n') + + // Trim to avoid weird formatting if the last line ends with a newline + buffer.append(text.trim()) } + val combinedCueText = buffer.toString() + + // Use the style of the first entry as the base entries .firstOrNull() ?.buildUpon() @@ -1006,6 +1196,7 @@ class CS3IPlayer : IPlayer { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() + // @OptIn(ExperimentalApi::class) val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, @@ -1060,10 +1251,27 @@ class CS3IPlayer : IPlayer { // Because "Java rules" the media3 team hates to do open classes so we have to copy paste the entire thing to add a custom extractor // This includes the updated MKV extractor that enabled seeking in formats where the seek information is at the back of the file val extractorFactor = UpdatedDefaultExtractorsFactory() + .setFragmentedMp4ExtractorFlags(FragmentedMp4Extractor.FLAG_MERGE_FRAGMENTED_SIDX) - val factory = - if (cacheFactory == null) DefaultMediaSourceFactory(context, extractorFactor) - else DefaultMediaSourceFactory(cacheFactory, extractorFactor) + // Create an online connection with cache for all online sources + val dataSourceFactory = if (onlineSource == null) { + null + } else { + if (simpleCache == null) + simpleCache = getCache(context, simpleCacheSize) + + val cacheFactory = CacheDataSource.Factory().apply { + simpleCache?.let { setCache(it) } + setUpstreamDataSourceFactory(onlineSource) + } + cacheFactory + } + + val defaultMediaSourceFactory = if (dataSourceFactory != null) { + DefaultMediaSourceFactory(dataSourceFactory, extractorFactor) + } else { + DefaultMediaSourceFactory(context, extractorFactor) + } // If there is only one item then treat it as normal, if multiple: concatenate the items. val videoMediaSource = if (mediaItemSlices.size == 1) { @@ -1071,9 +1279,10 @@ class CS3IPlayer : IPlayer { item.drm?.let { drm -> when (drm.uuid) { - CLEARKEY_UUID -> { + CLEARKEY_DRM_UUID.toJavaUuid() -> { // Use headers from DrmMetadata for media requests - val client = createOnlineSource(drm.headers) + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") val drmCallback = LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) val manager = DefaultDrmSessionManager.Builder() @@ -1091,10 +1300,11 @@ class CS3IPlayer : IPlayer { .createMediaSource(item.mediaItem) } - WIDEVINE_UUID, - PLAYREADY_UUID -> { + WIDEVINE_DRM_UUID.toJavaUuid(), + PLAYREADY_DRM_UUID.toJavaUuid() -> { // Use headers from DrmMetadata for media requests - val client = createOnlineSource(drm.headers) + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") val drmCallback = HttpMediaDrmCallback(drm.licenseUrl, client) val manager = DefaultDrmSessionManager.Builder() .setPlayClearSamplesWithoutKeys(true) @@ -1120,16 +1330,16 @@ class CS3IPlayer : IPlayer { } } } ?: run { - factory.createMediaSource(item.mediaItem) + defaultMediaSourceFactory.createMediaSource(item.mediaItem) } } else { try { val source = ConcatenatingMediaSource2.Builder() - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> source.add( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( - factory.createMediaSource(item.mediaItem), + defaultMediaSourceFactory.createMediaSource(item.mediaItem), item.durationUs ) ) @@ -1139,11 +1349,11 @@ class CS3IPlayer : IPlayer { @Suppress("DEPRECATION") val source = ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> 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), + defaultMediaSourceFactory.createMediaSource(item.mediaItem), item.durationUs ) ) @@ -1151,19 +1361,18 @@ class CS3IPlayer : IPlayer { source } } - - //println("PLAYBACK POS $playbackPosition") return exoPlayerBuilder.build().apply { setPlayWhenReady(playWhenReady) seekTo(currentWindow, playbackPosition) + // Merge video, subtitles and external audio tracks + val allSources = listOf(videoMediaSource) + subSources + audioSources setMediaSource( - MergingMediaSource( - videoMediaSource, *subSources.toTypedArray() - ), + MergingMediaSource(*allSources.toTypedArray()), playbackPosition ) setHandleAudioBecomingNoisy(true) setPlaybackSpeed(playBackSpeed) + this.addAnalyticsListener(tracksAnalyticsListener) } } @@ -1171,7 +1380,8 @@ class CS3IPlayer : IPlayer { context: Context, mediaSlices: List, subSources: List, - cacheFactory: CacheDataSource.Factory? = null + audioSources: List = emptyList(), + onlineSource: HttpDataSource.Factory? = null, ) { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -1195,14 +1405,32 @@ class CS3IPlayer : IPlayer { cacheSize = cacheSize, videoBufferMs = videoBufferMs, playWhenReady = isPlaying, // this keep the current state of the player - cacheFactory = cacheFactory, subtitleOffset = currentSubtitleOffset, - maxVideoHeight = maxVideoHeight + maxVideoHeight = maxVideoHeight, + audioSources = audioSources, + onlineSource = onlineSource, ) event(PlayerAttachedEvent(exoPlayer)) exoPlayer?.prepare() + // For offline fragmented MP4s, FLAG_MERGE_FRAGMENTED_SIDX builds the SIDX seek map + // incrementally as data is buffered. The initial seek resolves to the nearest merged + // entry (~first fragment, 3 s). On STATE_READY, re-seek to the actual saved position. + // This may only be reproducible on large and fairly long fragmented MP4 files with + // multiple sidx boxes. + if (onlineSource == null && playbackPosition > (exoPlayer?.duration ?: 0L)) { + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, playbackPosition) + exoPlayer?.removeListener(this) + } + }) + } + exoPlayer?.let { exo -> event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying @@ -1215,6 +1443,8 @@ class CS3IPlayer : IPlayer { return } + LiveHelper.registerPlayer(exoPlayer) + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { @@ -1239,7 +1469,7 @@ class CS3IPlayer : IPlayer { return@mapNotNull SubtitleData( // Nicer looking displayed names - fromTwoLettersToLanguage(format.language!!) + fromTagToLanguageName(format.language) ?: format.language!!, format.label ?: "", // See setPreferredTextLanguage @@ -1257,13 +1487,19 @@ class CS3IPlayer : IPlayer { } } - //fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. + // fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. + @Suppress("OVERRIDE_DEPRECATION") 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 + isPlaying = + when (playbackState) { + Player.STATE_ENDED -> CSPlayerLoading.IsEnded + Player.STATE_BUFFERING -> CSPlayerLoading.IsBuffering + else -> if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + } ) ) isPlaying = exo.isPlaying @@ -1317,6 +1553,23 @@ class CS3IPlayer : IPlayer { exoPlayer?.prepare() } + // PlaylistStuckException usually happens when the player position is ahead of the live window. + // Seek to the default location in that case + error.cause is HlsPlaylistTracker.PlaylistStuckException -> { + val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0 + + // Seek to live head + val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0 + + if (aheadOfLive > 100) { + exoPlayer?.seekTo(position - aheadOfLive) + } else { + exoPlayer?.seekToDefaultPosition() + } + exoPlayer?.prepare() + } + + else -> { event(ErrorEvent(error)) } @@ -1345,9 +1598,6 @@ class CS3IPlayer : IPlayer { } 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( @@ -1384,16 +1634,16 @@ class CS3IPlayer : IPlayer { onRenderFirst() updatedTime(source = PlayerEventSource.Player) } - }) + }.also { playerListener = it }) } catch (t: Throwable) { Log.e(TAG, "loadExo error", t) event(ErrorEvent(t)) } } - private var lastTimeStamps: List = emptyList() + private var lastTimeStamps: List = emptyList() - override fun addTimeStamps(timeStamps: List) { + override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> @@ -1402,7 +1652,7 @@ class CS3IPlayer : IPlayer { // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.startMs) + ?.setPosition(timestamp.timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() @@ -1416,20 +1666,6 @@ class CS3IPlayer : IPlayer { } Log.i(TAG, "Rendered first frame") hasUsedFirstRender = true - val invalid = exoPlayer?.duration?.let { duration -> - // Only errors short playback when not playing downloaded files - duration < 20_000L && currentDownloadedFile == null - // Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period - // If you can get the total time that'd be better, but this is already niche. - && exoPlayer?.currentTimeline?.periodCount == 1 - && exoPlayer?.isCurrentMediaItemLive != true - } ?: false - - if (invalid) { - releasePlayer(saveTime = false) - event(ErrorEvent(InvalidFileException("Too short playback"))) - return - } setPreferredSubtitles(currentSubtitles) val format = exoPlayer?.videoFormat @@ -1460,12 +1696,11 @@ class CS3IPlayer : IPlayer { val mediaItem = getMediaItem(MimeTypes.VIDEO_MP4, data.uri) val offlineSourceFactory = context.createOfflineSource() - val onlineSourceFactory = createOnlineSource(emptyMap()) val (subSources, activeSubtitles) = getSubSources( - onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subtitleHelper, + subHelper = subtitleHelper, + interceptor = null, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) @@ -1477,20 +1712,20 @@ class CS3IPlayer : IPlayer { } private fun getSubSources( - onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, + interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.getFixedUrl())) + val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) .setSelectionFlags(0) .build() when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { + SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) @@ -1501,37 +1736,41 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { - if (onlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(onlineSourceFactory.apply { - if (sub.headers.isNotEmpty()) - this.setDefaultRequestProperties(sub.headers) - }) - .createMediaSource(subConfig, TIME_UNSET) - } else { - null - } - } - - SubtitleOrigin.EMBEDDED_IN_VIDEO -> { - if (offlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(offlineSourceFactory) - .createMediaSource(subConfig, TIME_UNSET) - } else { - null - } + val dataSourceFactory = createOnlineSource(sub.headers, interceptor) + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(dataSourceFactory) + .createMediaSource(subConfig, TIME_UNSET) } } } return Pair(subSources, activeSubtitles) } + /** + * Creates audio media sources from ExtractorLink's audioTracks + * @param audioTracks List of audio tracks from ExtractorLink + * @return List of MediaSource for audio tracks + */ + private fun getAudioSources( + audioTracks: List, + interceptor: Interceptor?, + ): List { + return audioTracks.mapNotNull { audio -> + try { + val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) + val dataSourceFactory = createOnlineSource(audio.headers, interceptor) + DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) + } catch (e: Exception) { + Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") + null + } + } + } + override fun isActive(): Boolean { return exoPlayer != null } - @MainThread private fun loadTorrent(context: Context, link: ExtractorLink) { ioSafe { @@ -1581,7 +1820,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (e: Throwable) { + } catch (_: Throwable) { null } ?: default @@ -1601,7 +1840,7 @@ class CS3IPlayer : IPlayer { // this causes a *bug* that restarts all torrents from 0 // but I would call this a feature releasePlayer() - loadExo(context, listOf(), listOf(), null) + loadExo(context, listOf(), listOf()) } event( StatusEvent( @@ -1654,7 +1893,7 @@ class CS3IPlayer : IPlayer { if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) + sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true @@ -1676,11 +1915,10 @@ class CS3IPlayer : IPlayer { drm = DrmMetadata( kid = link.kid, key = link.key, - uuid = link.uuid, + uuid = link.uuid.toJavaUuid(), kty = link.kty, licenseUrl = link.licenseUrl, keyRequestParameters = link.keyRequestParameters, - headers = link.getAllHeaders() ) ) ) @@ -1692,26 +1930,46 @@ class CS3IPlayer : IPlayer { ) } - val onlineSourceFactory = createOnlineSource(link) + // For DASH or HLS single streams (non-playlist), prefer the player's default + // live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick + // the live/default position when no explicit start position was provided. + if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) { + playbackPosition = TIME_UNSET + } + + val provider = getApiFromNameNull(link.source) + val interceptor: Interceptor? = provider?.getVideoInterceptor(link) + + val onlineSourceFactory = + createVideoSource( + link = link, + engine = tryCreateEngine(context, simpleCacheSize), + interceptor = interceptor + ) + val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( - onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subtitleHelper + subHelper = subtitleHelper, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly + ) + + // Create audio sources from ExtractorLink's audioTracks + val audioSources = getAudioSources( + audioTracks = link.audioTracks, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - if (simpleCache == null) - simpleCache = getCache(context, simpleCacheSize) - - val cacheFactory = CacheDataSource.Factory().apply { - simpleCache?.let { setCache(it) } - setUpstreamDataSourceFactory(onlineSourceFactory) - } - - loadExo(context, mediaItems, subSources, cacheFactory) + loadExo( + context = context, + mediaSlices = mediaItems, + subSources = subSources, + audioSources = audioSources, + onlineSource = onlineSourceFactory + ) } catch (t: Throwable) { Log.e(TAG, "loadOnlinePlayer error", t) event(ErrorEvent(t)) @@ -1728,4 +1986,38 @@ class CS3IPlayer : IPlayer { loadOfflinePlayer(context, it) } } + + private val tracksAnalyticsListener = object : AnalyticsListener { + + override fun onVideoInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onAudioInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onVideoDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + + override fun onAudioDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + } + } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt new file mode 100644 index 000000000..c26a4f2df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* +* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes. +*/ +package com.lagradost.cloudstream3.ui.player + +import android.text.Html +import android.text.Spanned +import android.text.TextUtils +import androidx.annotation.VisibleForTesting +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Format.CueReplacementBehavior +import androidx.media3.common.text.Cue +import androidx.media3.common.text.Cue.AnchorType +import androidx.media3.common.util.Consumer +import androidx.media3.common.util.Log +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.SubtitleParser.OutputOptions +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.collect.ImmutableList +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** A [SubtitleParser] for SubRip. */ +@UnstableApi +class CustomSubripParser : SubtitleParser { + private val textBuilder: StringBuilder = StringBuilder() + private val tags: ArrayList = ArrayList() + private val parsableByteArray: ParsableByteArray = ParsableByteArray() + + override fun getCueReplacementBehavior(): @CueReplacementBehavior Int { + return CUE_REPLACEMENT_BEHAVIOR + } + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: OutputOptions, + output: Consumer + ) { + parsableByteArray.reset(data, /* limit= */offset + length) + parsableByteArray.setPosition(offset) + val charset = detectUtfCharset(parsableByteArray) + + val cuesWithTimingBeforeRequestedStartTimeUs: MutableList? = + if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues) + ArrayList() + else + null + var currentLine: String? + while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) { + if (currentLine!!.isEmpty()) { + // Skip blank lines. + continue + } + + // Parse and check the index line. + try { + currentLine.toInt() + } catch (_: NumberFormatException) { + Log.w(TAG, "Skipping invalid index: $currentLine") + continue + } + + // Read and parse the timing line. + currentLine = parsableByteArray.readLine(charset) + if (currentLine == null) { + Log.w(TAG, "Unexpected end") + break + } + + val startTimeUs: Long + val endTimeUs: Long + val matcher = SUBRIP_TIMING_LINE.matcher(currentLine) + if (matcher.matches()) { + startTimeUs = parseTimecode(matcher, /* groupOffset= */1) + endTimeUs = parseTimecode(matcher, /* groupOffset= */6) + } else { + Log.w(TAG, "Skipping invalid timing: $currentLine") + continue + } + + // Read and parse the text and tags. + textBuilder.setLength(0) + tags.clear() + currentLine = parsableByteArray.readLine(charset) + while (!TextUtils.isEmpty(currentLine)) { + if (textBuilder.isNotEmpty()) { + textBuilder.append("
") + } + textBuilder.append(processLine(currentLine!!, tags)) + currentLine = parsableByteArray.readLine(charset) + } + + @Suppress("DEPRECATION") + val text = Html.fromHtml(textBuilder.toString()) + + var alignmentTag: String? = null + for (i in tags.indices) { + val tag = tags[i] + if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) { + alignmentTag = tag + // Subsequent alignment tags should be ignored. + break + } + } + if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) { + output.accept( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } else cuesWithTimingBeforeRequestedStartTimeUs?.add( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } + if (cuesWithTimingBeforeRequestedStartTimeUs != null) { + for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) { + output.accept(cuesWithTiming) + } + } + } + + /** + * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if + * no BOM is found. + */ + private fun detectUtfCharset(data: ParsableByteArray): Charset { + val charset = data.readUtfCharsetFromBom() + return charset ?: StandardCharsets.UTF_8 + } + + /** + * Trims and removes tags from the given line. The removed tags are added to `tags`. + * + * @param line The line to process. + * @param tags A list to which removed tags will be added. + * @return The processed line. + */ + private fun processLine(line: String, tags: ArrayList): String { + var line = line + line = line.trim { it <= ' ' } + + var removedCharacterCount = 0 + val processedLine = StringBuilder(line) + val matcher = SUBRIP_TAG_PATTERN.matcher(line) + while (matcher.find()) { + val tag = matcher.group() + tags.add(tag) + val start = matcher.start() - removedCharacterCount + val tagLength = tag.length + processedLine.replace(start, /* end= */start + tagLength, /* str= */"") + removedCharacterCount += tagLength + } + + return processedLine.toString() + } + + /** + * Build a [Cue] based on the given text and alignment tag. + * + * @param text The text. + * @param alignmentTag The alignment tag, or `null` if no alignment tag is available. + * @return Built cue + */ + private fun buildCue(text: Spanned, alignmentTag: String?): Cue { + val cue = Cue.Builder().setText(text) + if (alignmentTag == null) { + return cue.build() + } + + // Horizontal alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + // Vertical alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) + .setLine( + getFractionalPositionForAnchorType(cue.getLineAnchor()), + Cue.LINE_TYPE_FRACTION + ) + .build() + } + + companion object { + /** + * The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this + * implementation. + */ + const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int = + Format.CUE_REPLACEMENT_BEHAVIOR_MERGE + + // Fractional positions for use when alignment tags are present. + private const val START_FRACTION = 0.08f + private const val END_FRACTION = 1 - START_FRACTION + private const val MID_FRACTION = 0.5f + + private const val TAG = "SubripParser" + + // The google devs are useless, this entire class is just to override this + private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?" + private val SUBRIP_TIMING_LINE: Pattern = + Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*") + + // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. + private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") + private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" + + // Alignment tags for SSA V4+. + private const val ALIGN_BOTTOM_LEFT = "{\\an1}" + private const val ALIGN_BOTTOM_MID = "{\\an2}" + private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" + private const val ALIGN_MID_LEFT = "{\\an4}" + private const val ALIGN_MID_MID = "{\\an5}" + private const val ALIGN_MID_RIGHT = "{\\an6}" + private const val ALIGN_TOP_LEFT = "{\\an7}" + private const val ALIGN_TOP_MID = "{\\an8}" + private const val ALIGN_TOP_RIGHT = "{\\an9}" + + private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { + val hours = matcher.group(groupOffset + 1) + var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 + timestampMs += checkNotNull(matcher.group(groupOffset + 2)) + .toLong() * 60 * 1000 + timestampMs += checkNotNull(matcher.group(groupOffset + 3)) + .toLong() * 1000 + val millis = matcher.group(groupOffset + 4) + + timestampMs += when (millis?.length) { + null -> 0L + 1 -> millis.toLong() * 100L + 2 -> millis.toLong() * 10L + 3 -> millis.toLong() * 1L + else -> millis.substring(0, 3).toLong() + } + + return timestampMs * 1000 + } + + // TODO(b/289983417): Make package-private again, once it is no longer needed in + // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) + @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) + fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { + return when (anchorType) { + Cue.ANCHOR_TYPE_START -> START_FRACTION + Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION + Cue.ANCHOR_TYPE_END -> END_FRACTION + Cue.TYPE_UNSET -> // Should never happen. + throw IllegalArgumentException() + + else -> + throw IllegalArgumentException() + } + } + } +} \ 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 dfef0de00..61d6f5564 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 @@ -18,7 +18,6 @@ import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.ssa.SsaParser -import androidx.media3.extractor.text.subrip.SubripParser import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser @@ -35,8 +34,8 @@ 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. - **/ -@UnstableApi + */ +@OptIn(UnstableApi::class) class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { @@ -53,15 +52,15 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } private const val DEFAULT_MARGIN: Float = 0.05f - private const val SSA_ALIGNMENT_BOTTOM_LEFT = 1 - private const val SSA_ALIGNMENT_BOTTOM_CENTER = 2 - private const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3 - private const val SSA_ALIGNMENT_MIDDLE_LEFT = 4 - private const val SSA_ALIGNMENT_MIDDLE_CENTER = 5 - private const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6 - private const val SSA_ALIGNMENT_TOP_LEFT = 7 - private const val SSA_ALIGNMENT_TOP_CENTER = 8 - private const val SSA_ALIGNMENT_TOP_RIGHT = 9 + const val SSA_ALIGNMENT_BOTTOM_LEFT = 1 + const val SSA_ALIGNMENT_BOTTOM_CENTER = 2 + const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3 + const val SSA_ALIGNMENT_MIDDLE_LEFT = 4 + const val SSA_ALIGNMENT_MIDDLE_CENTER = 5 + const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6 + const val SSA_ALIGNMENT_TOP_LEFT = 7 + const val SSA_ALIGNMENT_TOP_CENTER = 8 + const val SSA_ALIGNMENT_TOP_RIGHT = 9 /** Subtitle offset in milliseconds */ var subtitleOffset: Long = 0 @@ -108,7 +107,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } /** - * Fixes alignment for cues with {\anX}, + * Fixes alignment for cues with {\anX}, * this is common for .vtt that should be parsed as .srt * * ``` @@ -148,37 +147,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { // exoplayer can already parse this, however for eg webvtt it fails locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment -> // toLineAnchor - when (alignment) { - SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END - SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE - SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START - else -> null - }?.let { anchor -> - setLineAnchor(anchor) - setLine( - computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION - ) - } - // toPositionAnchor - when (alignment) { - SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START - SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE - SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END - else -> null - }?.let { anchor -> - setPositionAnchor(anchor) - setPosition(computeDefaultLineOrPosition(anchor)) - } - - // toTextAlignment - when (alignment) { - SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL - SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER - SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE - else -> null - }?.let { anchor -> - setTextAlignment(anchor) - } + this.setSubtitleAlignment(alignment) } // remove all matches, so we do not display \anx @@ -186,6 +155,42 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { setText(trimmed) return this } + + fun Cue.Builder.setSubtitleAlignment(alignment: Int?): Cue.Builder { + if (alignment == null) return this + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END + SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE + SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START + else -> null + }?.let { anchor -> + setLineAnchor(anchor) + setLine( + computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION + ) + } + // toPositionAnchor + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START + SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE + SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END + else -> null + }?.let { anchor -> + setPositionAnchor(anchor) + setPosition(computeDefaultLineOrPosition(anchor)) + } + + // toTextAlignment + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL + SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER + SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE + else -> null + }?.let { anchor -> + setTextAlignment(anchor) + } + return this + } } private var realDecoder: SubtitleParser? = null @@ -245,14 +250,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ignoreCase = true )) -> SsaParser(fallbackFormat?.initializationData) - trimmedText.startsWith("1", ignoreCase = true) -> SubripParser() + trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() fallbackFormat != null -> { - when (val mimeType = fallbackFormat.sampleMimeType) { + when (fallbackFormat.sampleMimeType) { MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_TTML -> TtmlParser() - MimeTypes.APPLICATION_SUBRIP -> SubripParser() + MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) // These decoders are not converted to parsers yet // TODO @@ -386,7 +391,7 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { /** * Decoders created here persists across reset() * Do not save state in the decoder which you want to reset (e.g subtitle offset) - **/ + */ override fun createDecoder(format: Format): SubtitleDecoder { val parser = CustomDecoder(format) // Allow garbage collection if player releases the decoder @@ -398,8 +403,8 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { } } -@OptIn(UnstableApi::class) /** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ +@OptIn(UnstableApi::class) class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : SimpleSubtitleDecoder(name) { 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 16eb88327..35f8dcfd8 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,60 +1,25 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri -import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.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.ExtractorLinkType +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder -import kotlin.math.max -import kotlin.math.min +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo class DownloadFileGenerator( - private val episodes: List, - private var currentIndex: Int = 0 -) : IGenerator { + episodes: List +) : VideoGenerator(episodes) { override val hasCache = false override val canSkipLoading = false - override fun hasNext(): Boolean { - return currentIndex < episodes.size - 1 - } - - override fun hasPrev(): Boolean { - return currentIndex > 0 - } - - override fun next() { - if (hasNext()) - currentIndex++ - } - - override fun prev() { - if (hasPrev()) - currentIndex-- - } - - override fun goto(index: Int) { - // clamps value - currentIndex = min(episodes.size - 1, max(0, index)) - } - - override fun getCurrentId(): Int? { - return episodes[currentIndex].id - } - - override fun getCurrent(offset: Int): Any? { - return episodes.getOrNull(currentIndex + offset) - } - - override fun getAll(): List? { - return null - } + override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id override suspend fun generateLinks( clearCache: Boolean, @@ -64,14 +29,14 @@ class DownloadFileGenerator( offset: Int, isCasting: Boolean ): Boolean { - val meta = episodes[currentIndex + offset] + val meta = videos.getOrNull(offset) ?: return false 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) + getDownloadFileInfo(act, id) } } @@ -90,17 +55,19 @@ class DownloadFileGenerator( getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> if (isMatchingSubtitle(name, display, cleanDisplay)) { val cleanName = cleanDisplayName(name) - val realName = cleanName.removePrefix(cleanDisplay) + val lastNum = Regex(" ([0-9]+)$") + val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: "" + val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim() subtitleCallback( SubtitleData( - realName.ifBlank { ctx.getString(R.string.default_subtitles) }, - "", + originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, + nameSuffix, uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), emptyMap(), - null + fromLanguageToTagIETF(originalName, 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 7fc297235..a086cc16f 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 @@ -11,9 +11,12 @@ import com.lagradost.cloudstream3.mvvm.safe 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.UIHelper.enableEdgeToEdgeCompat class DownloadedPlayerActivity : AppCompatActivity() { - private val dTAG = "DownloadedPlayerAct" + companion object { + const val TAG = "DownloadedPlayerActivity" + } override fun dispatchKeyEvent(event: KeyEvent): Boolean = CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) @@ -26,52 +29,83 @@ class DownloadedPlayerActivity : AppCompatActivity() { CommonActivity.onUserLeaveHint(this) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Ignore same intent so the player doesnt totally + // reload if you are playing the same thing. + if (isSameIntent(intent)) return + setIntent(intent) + Log.i(TAG, "onNewIntent") + handleIntent(intent) + } + + private fun isSameIntent(newIntent: Intent): Boolean { + val old = intent ?: return false + // Compare URIs first + val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri + val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri + if (oldUri != null && oldUri == newUri) return true + // Fall back to comparing EXTRA_TEXT links + val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) } + val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) } + return oldText != null && oldText == newText + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) CommonActivity.loadThemes(this) CommonActivity.init(this) + enableEdgeToEdgeCompat() setContentView(R.layout.empty_layout) - Log.i(dTAG, "onCreate") + Log.i(TAG, "onCreate") + handleIntent(intent) + /** + * Use moveTaskToBack instead of finish() so there is always exactly one task + * entry in recents, always reflecting the current file. + * + * finish() destroys the Activity but may leave the task in recents. Each new file + * open can create a new task entry, so recents accumulates stale entries for old + * files. The user then taps a stale entry and gets the wrong file. + * + * moveTaskToBack keeps the Activity alive in the background. There is only ever + * one task entry in recents. New files opened from the file manager arrive via + * onNewIntent on the live instance, updating the player immediately. The single + * recents entry always reflects the current state, ensuring we load the + * correct file. + */ + attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) } + } + + private fun handleIntent(intent: Intent) { val data = intent.data - if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { return } - if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) { - val extraText = safe { // I dont trust android - intent.getStringExtra(Intent.EXTRA_TEXT) - } + if ( + intent.action == Intent.ACTION_SEND || + intent.action == Intent.ACTION_OPEN_DOCUMENT || + intent.action == Intent.ACTION_VIEW + ) { + val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) } val cd = intent.clipData val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val url = item?.text?.toString() - - // idk what I am doing, just hope any of these work - if (item?.uri != null) - playUri(this, item.uri) - else if (url != null) - playLink(this, url) - else if (data != null) - playUri(this, data) - else if (extraText != null) - playLink(this, extraText) - else { - finish() - return + when { + item?.uri != null -> playUri(this, item.uri) + url != null -> playLink(this, url) + data != null -> playUri(this, data) + extraText != null -> playLink(this, extraText) + else -> finishAndRemoveTask() } } else if (data?.scheme == "content") { playUri(this, data) - } else { - finish() - return - } - - attachBackPressedCallback("DownloadedPlayerActivity") { finish() } + } else finishAndRemoveTask() } 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 794dd762d..85db33fc0 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 @@ -6,36 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType 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 - } - - override fun hasNext(): Boolean { - return false - } - - override fun getAll(): List? { - return null - } - - override fun hasPrev(): Boolean { - return false - } - - override fun getCurrent(offset: Int): Any? { - return null - } - - override fun goto(index: Int) {} - - override fun next() {} - - override fun prev() {} - +) : NoVideoGenerator(null) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt new file mode 100644 index 000000000..025267cc9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.os.Looper +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.text.TextOutput +import androidx.media3.exoplayer.text.TextRenderer +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory + +@UnstableApi +class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { + /** Somehow the nextlib authors decided that we need a text renderer that causes + * "ERROR_CODE_FAILED_RUNTIME_CHECK". + * + * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 + * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 + * */ + override fun buildTextRenderers( + context: Context, + output: TextOutput, + outputLooper: Looper, + extensionRendererMode: Int, + out: ArrayList + ) { + out.add(TextRenderer(output, outputLooper)) + } +} \ No newline at end of file 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 22cd22d3c..4ba933e13 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 @@ -5,56 +5,45 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog import android.content.Context +import android.content.DialogInterface import android.content.pm.ActivityInfo import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color -import android.media.AudioManager -import android.media.audiofx.LoudnessEnhancer import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.Settings import android.text.Editable -import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.Surface import android.view.View import android.view.ViewGroup -import android.view.WindowInsets import android.view.WindowManager -import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.animation.AnimationUtils +import android.view.animation.DecelerateInterpolator import android.widget.LinearLayout import androidx.annotation.OptIn -import androidx.core.content.ContextCompat +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.MimeTypes import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity.keyEventListener -import com.lagradost.cloudstream3.CommonActivity.playerEventListener -import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation -import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding +import com.lagradost.cloudstream3.databinding.SpeedDialogBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive @@ -64,59 +53,38 @@ 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.isUsingMobileData -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight -import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight -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 com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt -import kotlin.math.abs -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min -import kotlin.math.round -import kotlin.math.roundToInt +import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import kotlin.math.roundToInt - -const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking -const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage -const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage -const val VERTICAL_MULTIPLIER = 2.0f -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 +@OptIn(UnstableApi::class) +open class FullScreenPlayer : AbstractPlayerFragment( + BindingCreator.Bind(FragmentPlayerBinding::bind) +) { + override fun pickLayout(): Int = R.layout.fragment_player protected open var lockRotation = true - protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null - private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) - // state of player UI protected var isShowing = false protected var isLocked = false - + protected var timestampShowState = false + private var metadataVisibilityToken = 0 protected var hasEpisodes = false private set - //protected val hasEpisodes - // get() = episodes.isNotEmpty() - - // options for player /** * Default profile 1 @@ -125,21 +93,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { **/ protected var currentQualityProfile = 1 - // protected var currentPrefQuality = -// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell - protected var fastForwardTime = 10000L protected var androidTVInterfaceOffSeekTime = 10000L protected var androidTVInterfaceOnSeekTime = 30000L - protected var 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 + protected var rotatedManually = false private var hideControlsNames = false - protected var speedupEnabled = false protected var subtitleDelay set(value) = try { player.setSubtitleOffset(-value) @@ -153,47 +113,115 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 0L } - //private var useSystemBrightness = false - protected var useTrueSystemBrightness = true - private val fullscreenNotch = true //TODO SETTING + private var isShowingEpisodeOverlay: Boolean = false + private var previousPlayStatus: Boolean = false - private var statusBarHeight: Int? = null - private var navigationBarHeight: Int? = null + override fun fixLayout(view: View) = Unit - private val brightnessIcons = listOf( - R.drawable.sun_1, - R.drawable.sun_2, - R.drawable.sun_3, - R.drawable.sun_4, - R.drawable.sun_5, - R.drawable.sun_6, - //R.drawable.sun_7, - // R.drawable.ic_baseline_brightness_1_24, - // R.drawable.ic_baseline_brightness_2_24, - // R.drawable.ic_baseline_brightness_3_24, - // R.drawable.ic_baseline_brightness_4_24, - // R.drawable.ic_baseline_brightness_5_24, - // R.drawable.ic_baseline_brightness_6_24, - // R.drawable.ic_baseline_brightness_7_24, - ) + /** + * Wet code but this can not be made into a function as it is a setter. + * + * The reason for this setter is to fix a bug with the titlecard popup, as we want it to autohide + * when pressing back. + * + * Note that we move the call to autoHide after field assignment with prevField to avoid inf recursion. */ + protected var selectSourceDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectTrackDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSpeedDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSubtitlesDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } - private val volumeIcons = listOf( - R.drawable.ic_baseline_volume_mute_24, - R.drawable.ic_baseline_volume_down_24, - R.drawable.ic_baseline_volume_up_24, - ) + /** Checks if any top level dialog is open and showing */ + fun isDialogOpen() = + selectSourceDialog?.isShowing == true + || selectTrackDialog?.isShowing == true + || selectSpeedDialog?.isShowing == true + || selectSubtitlesDialog?.isShowing == true + || isShowingEpisodeOverlay - 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 + private fun scheduleMetadataVisibility() { + val metadataScrim = playerBinding?.playerMetadataScrim ?: return + val ctx = metadataScrim.context ?: return + + if (!ctx.shouldShowPlayerMetadata()) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } + + if (isLayout(PHONE)) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } + + val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused + val token = ++metadataVisibilityToken + + if (isPaused) { + metadataScrim.postDelayed({ + /** Make sure the user has not interacted with anything */ + if (token != metadataVisibilityToken) return@postDelayed + /** If already visible, then do not rerun the animation */ + if (metadataScrim.isVisible) return@postDelayed + /** Failsafe, as this should only be shown when paused */ + if (currentPlayerStatus != CSPlayerLoading.IsPaused) return@postDelayed + /** We do not want to show the logo in the background when the user is within another screen */ + if (isDialogOpen()) return@postDelayed + + metadataScrim.alpha = 0f + metadataScrim.isVisible = true + metadataScrim.animate() + .alpha(1f) + .setDuration(500L) + .setInterpolator(DecelerateInterpolator()) + .start() + hidePlayerUI() + }, 8000L) + } else { + if (metadataScrim.isVisible) { + metadataScrim.animate() + .alpha(0f) + .setDuration(300L) + .setInterpolator(AccelerateDecelerateInterpolator()) + .withEndAction { + metadataScrim.alpha = 0f // force final state + metadataScrim.isVisible = false + } + .start() + } + } } override fun onDestroyView() { + playerHostView?.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } @@ -214,41 +242,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { throw NotImplementedError() } - /** - * [isValidTouch] should be called on a [View] spanning across the screen for reliable results. - * - * Android has supported gesture navigation properly since API-30. We get the absolute screen dimens using - * [WindowManager.getCurrentWindowMetrics] and remove the stable insets - * {[WindowInsets.getInsetsIgnoringVisibility]} to get a safe perimeter. - * This approach supports any and all types of necessary system insets. - * - * @return false if the touch is on the status bar or navigation bar - * */ - private fun View.isValidTouch(rawX: Float, rawY: Float): Boolean { - // NOTE: screenWidth is without the navbar width when 3button nav is turned on. - if (Build.VERSION.SDK_INT >= 30) { - // real = absolute dimen without any default deductions like navbar width - val windowMetrics = - (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.currentWindowMetrics - val realScreenHeight = - windowMetrics?.let { windowMetrics.bounds.bottom - windowMetrics.bounds.top } - ?: screenHeightWithOrientation - val realScreenWidth = - windowMetrics?.let { windowMetrics.bounds.right - windowMetrics.bounds.left } - ?: screenWidthWithOrientation + open fun showEpisodesOverlay() { + throw NotImplementedError() + } - val insets = - rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) - val isOutsideHeight = rawY < insets.top || rawY > (realScreenHeight - insets.bottom) - val isOutsideWidth = if (windowMetrics == null) { - rawX < screenWidthWithOrientation - } else rawX < insets.left || rawX > realScreenWidth - insets.right - - return !(isOutsideWidth || isOutsideHeight) - } else { - val statusHeight = statusBarHeight ?: 0 - return rawY > statusHeight && rawX < screenWidthWithOrientation - } + open fun isThereEpisodes(): Boolean { + return false } override fun exitedPipMode() { @@ -258,7 +257,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun animateLayoutChangesForSubtitles() = // Post here as bottomPlayerBar is gone the first frame => bottomPlayerBar.height = 0 playerBinding?.bottomPlayerBar?.post { - @OptIn(UnstableApi::class) val sView = subView ?: return@post val sStyle = CustomDecoder.style val binding = playerBinding ?: return@post @@ -283,11 +281,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isShowing) { updateUIVisibility() } else { + toggleEpisodesOverlay(false) playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - playerBinding?.playerVideoTitle?.let { + playerBinding?.playerVideoTitleHolder?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() @@ -299,6 +298,19 @@ open class FullScreenPlayer : AbstractPlayerFragment() { start() } } + playerBinding?.playerVideoInfo?.let { + ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { + duration = 200 + start() + } + } + playerBinding?.playerMetadataScrim?.let { + ObjectAnimator.ofFloat(it, "translationY", 1f).apply { + duration = 200 + start() + } + } + val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { @@ -306,7 +318,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() { start() } } - + if (isLayout(PHONE)) { + playerBinding?.playerEpisodesButton?.let { + ObjectAnimator.ofFloat(it, "translationX", if (isShowing) 0f else 50.toPx.toFloat()) + .apply { + duration = 200 + start() + } + } + } val fadeTo = if (isShowing) 1f else 0f val fadeAnimation = AlphaAnimation(1f - fadeTo, fadeTo) @@ -326,25 +346,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } if (!isLocked) { - playerFfwdHolder.alpha = 1f - playerRewHolder.alpha = 1f - // player_pause_play_holder?.alpha = 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) downloadBothHeader.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) @@ -353,11 +358,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @OptIn(UnstableApi::class) override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> - track.mimeType == MimeTypes.APPLICATION_MEDIA3_CUES + track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = @@ -373,7 +377,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -387,14 +391,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } - open fun lockOrientation(activity: Activity) { - @Suppress("DEPRECATION") + private fun lockOrientation(activity: Activity) { val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + @Suppress("DEPRECATION") (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay else activity.display!! val rotation = display.rotation @@ -415,7 +419,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = dynamicOrientation() + else -> orientation = playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -426,55 +430,53 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isLocked) { lockOrientation(this) } else { - if (ignoreDynamicOrientation) { - // restore when lock is disabled + if (ignoreDynamicOrientation || rotatedManually) { + // Restore when lock is disabled. restoreOrientationWithSensor(this) } else { - this.requestedOrientation = dynamicOrientation() + this.requestedOrientation = + playerHostView?.dynamicOrientation() ?: return@apply } } } } } - protected fun enterFullscreen() { - if (isFullScreenPlayer) { - activity?.hideSystemUI() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { - val params = activity?.window?.attributes - params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - activity?.window?.attributes = params + private fun setupKeyEventListener() { + keyEventListener = { (event, hasNavigated) -> + when { + event == null -> false + event.action == KeyEvent.ACTION_DOWN && + (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || + event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) -> + playerHostView?.handleVolumeKey(event.keyCode) ?: false + + player.isActive() -> handleKeyEvent(event, hasNavigated) + else -> false } } - updateOrientation() - } - - protected fun exitFullscreen() { - //if (lockRotation) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER - - // simply resets brightness and notch settings that might have been overridden - val lp = activity?.window?.attributes - lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lp?.layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT - } - activity?.window?.attributes = lp - activity?.showSystemUI() } override fun onResume() { - enterFullscreen() - verifyVolume() + playerHostView?.enterFullscreen { updateOrientation() } + setupKeyEventListener() + playerHostView?.verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { - // netflix capture back and hide ~monke - if (isShowing && isLayout(TV or EMULATOR)) { + if (isShowingEpisodeOverlay) { + // isShowingEpisodeOverlay pauses, so this makes it easier to unpause + if (isLayout(TV or EMULATOR)) { + playerPausePlay?.requestFocus() + } + toggleEpisodesOverlay(show = false) + return@attachBackPressedCallback + } else if (isShowing && isLayout(TV or EMULATOR)) { + // netflix capture back and hide ~monke onClickChange() } else { activity?.popCurrentPage("FullScreenPlayer") } } + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -484,10 +486,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun onDestroy() { - exitFullscreen() - player.release() - player.releaseCallbacks() - player = CS3IPlayer() + playerHostView?.exitFullscreen() super.onDestroy() } @@ -520,30 +519,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) // Use dialog as opposed to alertdialog to get fullscreen - val dialog = Dialog(ctx, R.style.AlertDialogCustomBlack).apply { + val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { setContentView(binding.root) } + this.selectSubtitlesDialog = dialog dialog.show() - val beforeOffset = subtitleDelay + val isPortrait = + ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + fixSystemBarsPadding(binding.root, fixIme = isPortrait) + var currentOffset = subtitleDelay binding.apply { - var subtitleAdapter: SubtitleOffsetItemAdapter? = null - subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> - subtitleDelay = time - - // Scroll to the first active subtitle - val playerPosition = player.getPosition() ?: 0 - val totalPosition = playerPosition - subtitleDelay - subtitleAdapter?.updateTime(totalPosition) - - subtitleAdapter?.getLatestActiveItem(totalPosition) - ?.let { subtitlePos -> - subtitleOffsetRecyclerview.scrollToPosition(subtitlePos) - } - + currentOffset = time val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -561,19 +551,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } subtitleOffsetInput.text = - Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) + Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) val subtitles = player.getSubtitleCues().toMutableList() subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() - val initialSubtitlePosition = (player.getPosition() ?: 0) - subtitleDelay - subtitleAdapter = - SubtitleOffsetItemAdapter(initialSubtitlePosition, subtitles) { subtitleCue -> + val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset + val subtitleAdapter = + SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() ?.newEditable((playerPosition - subtitleCue.startTimeMs).toString()) + }.apply { + submitList(subtitles) } subtitleOffsetRecyclerview.adapter = subtitleAdapter @@ -607,147 +599,109 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } dialog.setOnDismissListener { - if (isFullScreenPlayer) - activity?.hideSystemUI() + selectSubtitlesDialog = null + activity?.hideSystemUI() } applyBtt.setOnClickListener { + selectSubtitlesDialog = null + subtitleDelay = currentOffset dialog.dismissSafe(activity) player.seekTime(1L) } resetBtt.setOnClickListener { + selectSubtitlesDialog = null subtitleDelay = 0 dialog.dismissSafe(activity) player.seekTime(1L) } cancelBtt.setOnClickListener { - subtitleDelay = beforeOffset + selectSubtitlesDialog = null dialog.dismissSafe(activity) } } } + @SuppressLint("SetTextI18n") + fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { + val speed = player.getPlaybackSpeed() + binding.speedText.text = "%.2fx".format(speed).replace(".0x", "x") + // Android crashes if you don't round to an exact step size + binding.speedBar.value = + (speed.coerceIn(0.1f, 2.0f) / binding.speedBar.stepSize).roundToInt() + .toFloat() * binding.speedBar.stepSize + } private fun showSpeedDialog() { - val speedsText = - listOf( - "0.5x", - "0.75x", - "0.85x", - "1x", - "1.15x", - "1.25x", - "1.4x", - "1.5x", - "1.75x", - "2x" - ) - val speedsNumbers = - listOf(0.5f, 0.75f, 0.85f, 1f, 1.15f, 1.25f, 1.4f, 1.5f, 1.75f, 2f) - val speedIndex = speedsNumbers.indexOf(player.getPlaybackSpeed()) + val act = activity ?: return + val isPlaying = player.getIsPlaying() + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - activity?.let { act -> - act.showDialog( - speedsText, - speedIndex, - act.getString(R.string.player_speed), - false, - { - if (isFullScreenPlayer) - activity?.hideSystemUI() - }) { index -> - if (isFullScreenPlayer) - activity?.hideSystemUI() - setPlayBackSpeed(speedsNumbers[index]) + val binding: SpeedDialogBinding = SpeedDialogBinding.inflate( + LayoutInflater.from(act) + ) + + updateSpeedDialogBinding(binding) + for ((view, speed) in arrayOf( + binding.speed25 to 0.25f, + binding.speed100 to 1.0f, + binding.speed125 to 1.25f, + binding.speed150 to 1.5f, + binding.speed200 to 2.0f, + )) { + view.setOnClickListener { + setPlayBackSpeed(speed) + updateSpeedDialogBinding(binding) } } - } - fun resetRewindText() { - playerBinding?.exoRewText?.text = - getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) - } - - fun resetFastForwardText() { - playerBinding?.exoFfwdText?.text = - getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) - } - - private fun rewind() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerRewHolder.alpha = 1f - - val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - playerRew.startAnimation(rotateLeft) - - 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 onAnimationEnd(animation: Animation?) { - exoRewText.post { - resetRewindText() - playerCenterMenu.isGone = !isShowing - playerRewHolder.alpha = if (isShowing) 1f else 0f - } - } - }) - exoRewText.startAnimation(goLeft) - exoRewText.text = - getString(R.string.rew_text_format).format(fastForwardTime / 1000) - } - player.seekTime(-fastForwardTime) - } catch (e: Exception) { - logError(e) + binding.speedMinus.setOnClickListener { + setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) + updateSpeedDialogBinding(binding) } - } - private fun fastForward() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerFfwdHolder.alpha = 1f - - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - playerFfwd.startAnimation(rotateRight) - - 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 onAnimationEnd(animation: Animation?) { - exoFfwdText.post { - resetFastForwardText() - playerCenterMenu.isGone = !isShowing - playerFfwdHolder.alpha = if (isShowing) 1f else 0f - } - } - }) - exoFfwdText.startAnimation(goRight) - exoFfwdText.text = - getString(R.string.ffw_text_format).format(fastForwardTime / 1000) - } - player.seekTime(fastForwardTime) - } catch (e: Exception) { - logError(e) + binding.speedPlus.setOnClickListener { + setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) + updateSpeedDialogBinding(binding) } + + binding.speedBar.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + setPlayBackSpeed(value) + updateSpeedDialogBinding(binding) + } + } + + val dismiss = DialogInterface.OnDismissListener { + activity?.hideSystemUI() + if (isPlaying) { + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + } + selectSpeedDialog = null + } + + // if (isLayout(PHONE)) { + // val builder = + // BottomSheetDialog(act, R.style.AlertDialogCustom) + // builder.setContentView(binding.root) + // builder.setOnDismissListener(dismiss) + // builder.show() + //} else { + val builder = + AlertDialog.Builder(act, R.style.AlertDialogCustom) + .setView(binding.root) + builder.setOnDismissListener(dismiss) + val dialog = builder.create() + this.selectSpeedDialog = dialog + dialog.show() + //} } private fun onClickChange() { isShowing = !isShowing - if (isShowing) { - playerBinding?.playerIntroPlay?.isGone = true - autoHide() - } - if (isFullScreenPlayer) - activity?.hideSystemUI() + if (isShowing) autoHide() + activity?.hideSystemUI() animateLayoutChanges() - playerBinding?.playerPausePlay?.requestFocus() + if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { @@ -756,6 +710,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + playerHostView?.isLocked = isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { @@ -767,41 +722,37 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { - val fadeAnimation = AlphaAnimation(playerVideoTitle.alpha, fadeTo).apply { + val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { duration = 100 fillAfter = true } updateUIVisibility() - // MENUS - //centerMenu.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) - //if (hasEpisodes) - // player_episodes_button?.startAnimation(fadeAnimation) - //player_media_route_button?.startAnimation(fadeAnimation) - //video_bar.startAnimation(fadeAnimation) + if (hasEpisodes) + playerEpisodesButton.startAnimation(fadeAnimation) + // player_media_route_button?.startAnimation(fadeAnimation) + // video_bar.startAnimation(fadeAnimation) - //TITLE + // TITLE playerVideoTitleRez.startAnimation(fadeAnimation) + playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) - playerVideoTitle.startAnimation(fadeAnimation) + playerVideoTitleHolder.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) // BOTTOM playerLockHolder.startAnimation(fadeAnimation) - //player_go_back_holder?.startAnimation(fadeAnimation) - + // player_go_back_holder?.startAnimation(fadeAnimation) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) } updateLockUI() } - open fun updateUIVisibility() { + private fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -812,22 +763,23 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } playerBinding?.apply { - playerLockHolder.isGone = isGone playerVideoBar.isGone = isGone - playerPausePlay.isGone = isGone - //player_buffering?.isGone = isGone + playerPausePlayHolderHolder.isGone = + isGone || currentPlayerStatus == CSPlayerLoading.IsBuffering playerTopHolder.isGone = isGone - //player_episodes_button?.isVisible = !isGone && hasEpisodes - playerVideoTitle.isGone = togglePlayerTitleGone -// player_video_title_rez?.isGone = isGone + val showPlayerEpisodes = !isGone && isThereEpisodes() + playerEpisodesButtonRoot.isVisible = showPlayerEpisodes + playerEpisodesButton.isVisible = showPlayerEpisodes + playerVideoTitleHolder.isGone = togglePlayerTitleGone + playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() playerEpisodeFiller.isGone = isGone playerCenterMenu.isGone = isGone playerLock.isGone = !isShowing - //player_media_route_button?.isClickable = !isGone playerGoBackHolder.isGone = isGone playerSourcesBtt.isGone = isGone + shadowOverlay.isGone = isGone playerSkipEpisode.isClickable = !isGone } } @@ -835,487 +787,237 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 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)) - } + 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)) } } } - private var currentTapIndex = 0 protected fun autoHide() { - currentTapIndex++ - delayHide() + metadataVisibilityToken++ + playerHostView?.scheduleAutoHide() + scheduleMetadataVisibility() + } + + override fun onAutoHideUI() { + if (player.getIsPlaying()) onClickChange() + } + + protected fun hidePlayerUI() { + if (isShowing) { + isShowing = false + animateLayoutChanges() + } + } + + /** PlayerView.Callbacks touch overrides */ + + override fun isUIShowing(): Boolean = isShowing + + override fun onSingleTap() { + onClickChange() + } + + override fun onTouchDown() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) + } + + @SuppressLint("SetTextI18n") + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } + + override fun onHidePlayerUI() { + hidePlayerUI() + } + + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + animateLayoutChanges() + } + autoHide() } override fun playerStatusChanged() { super.playerStatusChanged() - delayHide() + scheduleMetadataVisibility() } - private fun delayHide() { - val index = currentTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) onClickChange() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + // If we rotate the device we need to recalculate the zoom + val gh = playerHostView?.gestureHelper ?: return + val matrix = gh.zoomMatrix + val animation = gh.matrixAnimation + if ((animation == null || !animation.isRunning) && matrix != null) { + // Ignore if we have no zoom or mid-animation + playerView?.post { + gh.applyZoomMatrix(matrix, true) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + } + } + } + + override fun resize(resize: PlayerResize, showToast: Boolean) { + super.resize(resize, showToast) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + } + + private fun handleKeyDownEvent(keyCode: Int): Boolean? { + // adb shell input keyevent [INT] + when (keyCode) { + KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + player.handleEvent(CSPlayerEvent.SeekForward) + } + + KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.handleEvent(CSPlayerEvent.SeekBack) + } + + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { + player.handleEvent(CSPlayerEvent.PrevEpisode) + } + + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + player.handleEvent(CSPlayerEvent.Pause) + } + + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { + player.handleEvent(CSPlayerEvent.Play) + } + + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { + toggleLock() + } + + KeyEvent.KEYCODE_H -> { onClickChange() } - }, 2000) - } - // this is used because you don't want to hide UI when double tap seeking - private var currentDoubleTapIndex = 0 - private fun toggleShowDelayed() { - if (doubleTapEnabled || doubleTapPauseEnabled) { - val index = currentDoubleTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (index == currentDoubleTapIndex) { - onClickChange() - } - }, DOUBLE_TAB_MINIMUM_TIME_BETWEEN) - } else { - onClickChange() - } - } - - private var isCurrentTouchValid = false - private var currentTouchStart: Vector2? = null - private var currentTouchLast: Vector2? = null - private var currentTouchAction: TouchAction? = null - private var currentLastTouchAction: TouchAction? = null - private var currentTouchStartPlayerTime: Long? = - null // the time in the player when you first click - private var currentTouchStartTime: Long? = null // the system time when you first click - private var currentLastTouchEndTime: Long = 0 // the system time when you released your finger - private var currentClickCount: Int = - 0 // amount of times you have double clicked, will reset when other action is taken - - // requested volume and brightness is used to make swiping smoother - // to make it not jump between values, - // this value is within the range [0,2] where 1+ is loudness - private var currentRequestedVolume: Float = 0.0f - - // this value is within the range [0,1] - private var currentRequestedBrightness: Float = 1.0f - - enum class TouchAction { - Brightness, - Volume, - Time, - } - - companion object { - private fun forceLetters(inp: Long, letters: Int = 2): String { - val added: Int = letters - inp.toString().length - return if (added > 0) { - "0".repeat(added) + inp.toString() - } else { - inp.toString() + KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { + player.handleEvent(CSPlayerEvent.ToggleMute) } - } - private fun convertTimeToString(sec: Long): String { - val rsec = sec % 60L - val min = ceil((sec - rsec) / 60.0).toInt() - val rmin = min % 60L - val h = ceil((min - rmin) / 60.0).toLong() - //int rh = h;// h % 24; - return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters( - rmin - ) + ":" else "") + forceLetters( - rsec - ) - } - } - - private fun calculateNewTime( - startTime: Long?, - touchStart: Vector2?, - touchEnd: Vector2? - ): Long? { - if (touchStart == null || touchEnd == null || startTime == null) return null - val diffX = - (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() - val duration = player.getDuration() ?: return null - return max( - min( - startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), - duration - ), 0 - ) - } - - private fun getBrightness(): Float? { - return if (useTrueSystemBrightness) { - try { - Settings.System.getInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS - ) / 255f - } catch (e: Exception) { - // because true system brightness requires - // permission, this is a lazy way to check - // as it will throw an error if we do not have it - useTrueSystemBrightness = false - return getBrightness() + KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { + showMirrorsDialogue() } - } else { - try { - activity?.window?.attributes?.screenBrightness - } catch (e: Exception) { - logError(e) - null - } - } - } - - private fun setBrightness(brightness: Float) { - if (useTrueSystemBrightness) { - try { - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS_MODE, - Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL - ) - - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, (brightness * 255).toInt() - ) - } catch (e: Exception) { - useTrueSystemBrightness = false - setBrightness(brightness) - } - } else { - try { - val lp = activity?.window?.attributes - lp?.screenBrightness = brightness - activity?.window?.attributes = lp - } catch (e: Exception) { - logError(e) - } - } - } - - private var isVolumeLocked: Boolean = false - private var hasShownVolumeToast: Boolean = false - - private var progressBarLeftHideRunnable: Runnable? = null - private var progressBarRightHideRunnable: Runnable? = null - - // Verifies that the currentRequestedVolume matches the system volume - // if not, then it removes changes currentRequestedVolume and removes the loudnessEnhancer - // if the real volume is less than 100% - // - // This is here to make returning to the player less jarring, if we change the volume outside - // the app. Note that this will make it a bit wierd when using loudness in PiP, then returning - // however that is the cost of correctness. - private fun verifyVolume() { - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - val currentVolumeStep = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolumeStep = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - // if we can set the volume directly then do it - if (currentVolumeStep < maxVolumeStep || currentRequestedVolume <= 1.0f) { - currentRequestedVolume = - currentVolumeStep.toFloat() / maxVolumeStep.toFloat() - - loudnessEnhancer?.release() - loudnessEnhancer = null - } - } - } - - val holdhandler = Handler(Looper.getMainLooper()) - var hasTriggeredSpeedUp = false - val holdRunnable = Runnable { - player.setPlaybackSpeed(2.0f) - playerBinding?.playerSpeedupButton?.isGone = false - hasTriggeredSpeedUp = true - } - - @SuppressLint("SetTextI18n") - private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { - if (event == null || view == null) return false - val currentTouch = Vector2(event.x, event.y) - val startTouch = currentTouchStart - - playerBinding?.apply { - playerIntroPlay.isGone = true - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - // validates if the touch is inside of the player area - isCurrentTouchValid = view.isValidTouch(currentTouch.x, currentTouch.y) - /*if (isCurrentTouchValid && player_episode_list?.isVisible == true) { - player_episode_list?.isVisible = false - } else*/ if (isCurrentTouchValid) { - if (speedupEnabled) { - hasTriggeredSpeedUp = false - if (player.getIsPlaying() && !isLocked && isFullScreenPlayer) { - holdhandler.postDelayed(holdRunnable, 500) - } - } - isVolumeLocked = currentRequestedVolume < 1.0f - if (currentRequestedVolume <= 1.0f) { - hasShownVolumeToast = false - } - - currentTouchStartTime = System.currentTimeMillis() - currentTouchStart = currentTouch - currentTouchLast = currentTouch - currentTouchStartPlayerTime = player.getPosition() - - getBrightness()?.let { - currentRequestedBrightness = it - } - verifyVolume() - } - } - - MotionEvent.ACTION_UP -> { - holdhandler.removeCallbacks(holdRunnable) - if (hasTriggeredSpeedUp) { - player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) - playerSpeedupButton?.isGone = true - } - 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 - ) { - if (!isLocked - && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short - ) { - currentClickCount++ - - if (currentClickCount >= 1) { // have double clicked - currentDoubleTapIndex++ - if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen - when { - currentTouch.x < screenWidthWithOrientation / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { - if (doubleTapEnabled) - rewind() - } - - currentTouch.x > screenWidthWithOrientation / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { - if (doubleTapEnabled) - fastForward() - } - - else -> { - player.handleEvent( - CSPlayerEvent.PlayPauseToggle, - PlayerEventSource.UI - ) - } - } - } else if (doubleTapEnabled && isFullScreenPlayer) { - if (currentTouch.x < screenWidthWithOrientation / 2) { - rewind() - } else { - fastForward() - } - } - } - } else { - // is a valid click but not fast enough for seek - currentClickCount = 0 - if (!hasTriggeredSpeedUp) { - toggleShowDelayed() - } - //onClickChange() - } - } else { - currentClickCount = 0 - } - - // call auto hide as it wont hide when you have your finger down - autoHide() - - // reset variables - isCurrentTouchValid = false - currentTouchStart = null - currentLastTouchAction = currentTouchAction - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - - // resets UI - playerTimeText.isVisible = false - - currentLastTouchEndTime = System.currentTimeMillis() - } - - MotionEvent.ACTION_MOVE -> { - // if current touch is valid - - if (hasTriggeredSpeedUp) { - return true - } - 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 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { - // left = Brightness, right = Volume, but the UI is reversed to show the UI better - currentTouchAction = - if (startTouch.x < screenWidthWithOrientation / 2) { - // hide the UI if you hold brightness to show screen better, better UX - if (isShowing) { - isShowing = false - animateLayoutChanges() - } - - TouchAction.Brightness - } else { - TouchAction.Volume - } - } - } - if (swipeHorizontalEnabled) { - if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { - currentTouchAction = TouchAction.Time - } - } - } - - // display action - val lastTouch = currentTouchLast - if (lastTouch != null) { - val diffFromLast = lastTouch - currentTouch - val verticalAddition = - diffFromLast.y * VERTICAL_MULTIPLIER / screenHeightWithOrientation.toFloat() - - // update UI - playerTimeText.isVisible = false - - when (currentTouchAction) { - TouchAction.Time -> { - holdhandler.removeCallbacks(holdRunnable) - // this simply updates UI as the seek logic happens on release - // startTime is rounded to make the UI sync in a nice way - val startTime = - currentTouchStartPlayerTime?.div(1000L)?.times(1000L) - if (startTime != null) { - calculateNewTime( - startTime, - startTouch, - currentTouch - )?.let { newMs -> - val skipMs = newMs - startTime - playerTimeText.apply { - text = - "${convertTimeToString(newMs / 1000)} [${ - (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) - }${convertTimeToString(abs(skipMs / 1000))}]" - isVisible = true - } - } - } - } - - TouchAction.Brightness -> { - holdhandler.removeCallbacks(holdRunnable) - playerBinding?.playerProgressbarRightHolder?.apply { - if (!isVisible || alpha < 1f) { - alpha = 1f - isVisible = true - } - - progressBarRightHideRunnable?.let { removeCallbacks(it) } - progressBarRightHideRunnable = Runnable { - // Fade out the progress bar - animate().cancel() - animate() - .alpha(0f) - .setDuration(300) - .withEndAction { isVisible = false } - .start() - } - // Show the progress bar for 1.5 seconds - postDelayed(progressBarRightHideRunnable, 1500) - } - - val lastRequested = currentRequestedBrightness - currentRequestedBrightness = - min( - 1.0f, - max(currentRequestedBrightness + verticalAddition, 0.0f) - ) - - // this is to not spam request it, just in case it fucks over someone - if (lastRequested != currentRequestedBrightness) - setBrightness(currentRequestedBrightness) - - // max is set high to make it smooth - playerProgressbarRight.max = 100_000 - playerProgressbarRight.progress = - max(2_000, (currentRequestedBrightness * 100_000f).toInt()) - - playerProgressbarRightIcon.setImageResource( - brightnessIcons[min( // clamp the value just in case - brightnessIcons.size - 1, - max( - 0, - round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() - ) - )] - ) - } - - TouchAction.Volume -> { - holdhandler.removeCallbacks(holdRunnable) - handleVolumeAdjustment( - verticalAddition, - false - ) - } - - else -> Unit - } - } - } + // OpenSubtitles shortcut + KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { + val context = context + if (subsProvidersIsActive && context != null) { + openOnlineSubPicker(context, null) {} } } + + KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { + showSpeedDialog() + } + + KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { + nextResize() + } + + KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { + skipOp() + } + + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } + + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER -> { // space is not captured due to navigation + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + // KEYCODE_DPAD_CENTER and KEYCODE_ENTER both act as a "select/confirm" button. + // Some remotes (e.g. LG Magic Remote) send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER. + // When the player UI or a dialog is visible, we let the event pass through (return null) + // so the focused button/item can handle the click normally, rather than always toggling + // play/pause. Only when the UI is hidden do we treat it as a play/pause toggle. + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER -> { + if (isShowing || isDialogOpen()) { + return null + } + // If UI is not shown make click instantly skip to next chapter even if locked + if (timestampShowState) { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } else if (!isLocked) { + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + onClickChange() + } + + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP -> { + if (isShowing || isShowingEpisodeOverlay) { + return null + } + onClickChange() + } + + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(-androidTVInterfaceOffSeekTime) + return true + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(-androidTVInterfaceOnSeekTime) + return true + } else { + return null + } + } + + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(androidTVInterfaceOffSeekTime) + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(androidTVInterfaceOnSeekTime) + } else { + return null + } + } + + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_VOLUME_UP -> { + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). + if (playerHostView?.handleVolumeKey(keyCode) != true) { + return null + } + } + + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_SETTINGS -> { + if (isLocked || !isThereEpisodes()) { + return null + } + toggleEpisodesOverlay(true) + } + else -> return null // Avoid capturing all input } - currentTouchLast = currentTouch return true } - @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -1324,69 +1026,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { val keyCode = event.keyCode if (event.action == KeyEvent.ACTION_DOWN) { - when (keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (!isShowing) { - if (!isLocked) player.handleEvent(CSPlayerEvent.PlayPauseToggle) - onClickChange() - 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) { - player.seekTime(-androidTVInterfaceOnSeekTime) - return true - } - } - - KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (!isShowing && !isLocked) { - player.seekTime(androidTVInterfaceOffSeekTime) - return true - } else if (playerBinding?.playerPausePlay?.isFocused == true) { - player.seekTime(androidTVInterfaceOnSeekTime) - return true - } - } - - KeyEvent.KEYCODE_VOLUME_DOWN, - KeyEvent.KEYCODE_VOLUME_UP -> { - if (isLayout(PHONE or EMULATOR)) { - /** - * Some TVs do not support volume boosting, and overriding - * the volume buttons can be inconvenient for TV users. - * Since boosting volume is mainly useful on phones and emulators, - * we limit this feature to those devices. - */ - verifyVolume() - if (currentRequestedVolume <= 1.0f) { - hasShownVolumeToast = false - } - isVolumeLocked = currentRequestedVolume < 1.0f - handleVolumeAdjustment( - // +- 5% - if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { - 0.05f - } else { - -0.05f - }, - true - ) - return true - } - } + val value = handleKeyDownEvent(keyCode) + if (value != null) { + return value } } @@ -1419,142 +1061,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return false } - private var loudnessEnhancer: LoudnessEnhancer? = null - - @OptIn(UnstableApi::class) - private fun handleVolumeAdjustment( - delta: Float, - fromButton: Boolean, - ) { - val audioManager = - activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return - val currentVolumeStep = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolumeStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - val currentVolume = currentRequestedVolume - val isCurrentVolumeLocked = isVolumeLocked - - val nextVolume = - (currentVolume + delta).coerceIn(0.0f, if (isCurrentVolumeLocked) 1.0f else 2.0f) - - val nextVolumeStep = - (nextVolume * maxVolumeStep.toFloat()).roundToInt().coerceIn(0, maxVolumeStep) - - // show toast - if (fromButton) { - // for button related request we only show a toast when we exceeded the volume - if (currentVolume <= 1.0f && nextVolume > 1.0f && !hasShownVolumeToast) { - showToast(R.string.volume_exceeded_100) - hasShownVolumeToast = true - } - } else { - val nextRequestedVolume = currentVolume + delta - - // for swipes, we show toast that we need to swipe again - if (nextRequestedVolume > 1.0 && isCurrentVolumeLocked && !hasShownVolumeToast) { - showToast(R.string.slide_up_again_to_exceed_100) - hasShownVolumeToast = true - } - } - - // set the current volume step - if (nextVolumeStep != currentVolumeStep) { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, nextVolumeStep, 0) - } - - var hasBoostError = false - - // Apply loudness enhancer for volumes > 100%, removes it if less - if (nextVolume > 1.0f) { - val boostFactor = ((nextVolume - 1.0f) * 1000).toInt() - val currentEnhancer = loudnessEnhancer - - if (currentEnhancer != null) { - currentEnhancer.setTargetGain(boostFactor) - } else { - val audioSessionId = (playerView?.player as? ExoPlayer)?.audioSessionId - if (audioSessionId != null && audioSessionId != AudioManager.ERROR) { - try { - loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply { - setTargetGain(boostFactor) - enabled = true - } - } catch (t: Throwable) { - logError(t) - hasBoostError = true - } - } - } - } else { - loudnessEnhancer?.release() - loudnessEnhancer = null - } - - currentRequestedVolume = nextVolume - - // Update the progress bar - playerBinding?.apply { - val level1ProgressBar = playerProgressbarLeftLevel1 - val level2ProgressBar = playerProgressbarLeftLevel2 - - // Change color to show that LoudnessEnhancer broke - // this is not a real fix, but solves the crash issue - if (nextVolume > 1.0f) { - level2ProgressBar.progressTintList = ColorStateList.valueOf( - ContextCompat.getColor( - level2ProgressBar.context, if (hasBoostError) { - R.color.colorPrimaryRed - } else { - R.color.colorPrimaryOrange - } - ) - ) - } - - level1ProgressBar.max = 100_000 - level1ProgressBar.progress = - (nextVolume * 100_000f).toInt().coerceIn(2_000, 100_000) - - level2ProgressBar.max = 100_000 - level2ProgressBar.progress = - if (nextVolume > 1.0f) ((nextVolume - 1.0) * 100_000f).toInt() - .coerceIn(2_000, 100_000) else 0 - level2ProgressBar.isVisible = nextVolume > 1.0f - - // Calculate the clamped index for the volume icon based on the requested volume - val iconIndex = (nextVolume * (volumeIcons.lastIndex)) - .roundToInt() - .coerceIn(0, volumeIcons.lastIndex) - - // Update icon - playerProgressbarLeftIcon.setImageResource(volumeIcons[iconIndex]) - } - - // alpha fade - playerBinding?.playerProgressbarLeftHolder?.apply { - if (!isVisible || alpha < 1f) { - alpha = 1f - isVisible = true - } - - progressBarLeftHideRunnable?.let { removeCallbacks(it) } - progressBarLeftHideRunnable = Runnable { - // Fade out the progress bar - animate().cancel() - animate() - .alpha(0f) - .setDuration(300) - .withEndAction { isVisible = false } - .start() - } - // Show the progress bar for 1.5 seconds - postDelayed(progressBarLeftHideRunnable, 1500) - } - } - protected fun uiReset() { + metadataVisibilityToken++ + playerBinding?.playerMetadataScrim?.let { + it.animate().cancel() + it.alpha = 0f + it.isVisible = false + } isShowing = false - + toggleEpisodesOverlay(false) // if nothing has loaded these buttons should not be visible playerBinding?.apply { playerSkipEpisode.isVisible = false @@ -1566,8 +1081,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() updateUIVisibility() animateLayoutChanges() - resetFastForwardText() - resetRewindText() + playerHostView?.gestureHelper?.resetFastForwardText() + playerHostView?.gestureHelper?.resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1576,109 +1091,35 @@ open class FullScreenPlayer : AbstractPlayerFragment() { super.onSaveInstanceState(outState) } - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { + // Set up playerBinding before super initializes the player + // (brightness overlay is now injected by PlayerView.initialize()) + playerBinding = + PlayerCustomLayoutBinding.bind(binding.root.findViewById(R.id.player_holder)) + + super.onBindingCreated(binding, savedInstanceState) + + // This player is always full-screen; tell PlayerView so volume-key handling is active. + playerHostView?.isFullScreen = true + + // Wire up the snap-hint outline view and schedule brightness overlay bounds update + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + + val view = binding.root // init variables setPlayBackSpeed(DataStoreHelper.playBackSpeed) savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { subtitleDelay = it } - // handle tv controls - playerEventListener = { eventType -> - when (eventType) { - 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() - } - } - } - // handle tv controls directly based on player state - keyEventListener = { eventNav -> - // Don't hook player keys if player isn't active - if (player.isActive()) { - val (event, hasNavigated) = eventNav - if (event != null) - handleKeyEvent(event, hasNavigated) - else false - } else false - } + setupKeyEventListener() try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - fastForwardTime = - settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) - .toLong() * 1000L - androidTVInterfaceOffSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_off_seek_key), @@ -1692,16 +1133,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ) .toLong() * 1000L - navigationBarHeight = ctx.getNavigationBarHeight() - statusBarHeight = ctx.getStatusBarHeight() - - swipeHorizontalEnabled = - settingsManager.getBoolean(ctx.getString(R.string.swipe_enabled_key), true) - swipeVerticalEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.swipe_vertical_enabled_key), - true - ) playBackSpeedEnabled = settingsManager.getBoolean( ctx.getString(R.string.playback_speed_enabled_key), false @@ -1710,58 +1141,31 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 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), true ) - doubleTapEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_enabled_key), - false - ) - - doubleTapPauseEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_pause_enabled_key), - false - ) - hideControlsNames = settingsManager.getBoolean( ctx.getString(R.string.hide_player_control_names_key), false ) - speedupEnabled = settingsManager.getBoolean( - ctx.getString(R.string.speedup_key), - false - ) - - val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) QualityDataHelper.QualityProfileType.Data else QualityDataHelper.QualityProfileType.WiFi currentQualityProfile = - profiles.firstOrNull { it.type == type }?.id ?: profiles.firstOrNull()?.id - ?: currentQualityProfile - -// currentPrefQuality = settingsManager.getInt( -// ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), -// currentPrefQuality -// ) - // useSystemBrightness = - // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) + profiles.firstOrNull { it.types.contains(type) }?.id + ?: profiles.firstOrNull()?.id + ?: currentQualityProfile } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled playerResizeBtt.isVisible = playerResizeEnabled - playerRotateBtt.isVisible = playerRotateEnabled + playerRotateBtt.isVisible = + if (isLayout(TV or EMULATOR)) false else playerRotateEnabled if (hideControlsNames) { hideControlsNames() } @@ -1771,13 +1175,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } playerBinding?.apply { - if (isLayout(TV or EMULATOR)) { mapOf( playerGoBack to playerGoBackText, playerRestart to playerRestartText, playerGoForward to playerGoForwardText, downloadHeaderToggle to downloadHeaderToggleText, + playerEpisodesButton to playerEpisodesButtonText ).forEach { (button, text) -> button.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) { @@ -1785,25 +1189,17 @@ open class FullScreenPlayer : AbstractPlayerFragment() { text.isVisible = false return@setOnFocusChangeListener } + if (button.id == R.id.player_episodes_button) { + toggleEpisodesOverlay(show = true) + } else { + toggleEpisodesOverlay(show = false) + } 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) } @@ -1853,16 +1249,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showSubtitleOffsetDialog() } - playerRew.setOnClickListener { - autoHide() - rewind() - } - - playerFfwd.setOnClickListener { - autoHide() - fastForward() - } - playerGoBack.setOnClickListener { activity?.popCurrentPage("FullScreenPlayer") } @@ -1875,20 +1261,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 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) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> + autoHide() + } } + exoProgress.registerPlayerView(playerView) + + @SuppressLint("ClickableViewAccessibility") exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ - } - + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - currentTapIndex++ + playerHostView?.cancelAutoHide() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -1897,11 +1284,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } return@setOnTouchListener false } - } - // cs3 is peak media center - setRemainingTimeCounter(durationMode || isLayout(TV)) - playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> - updateRemainingTime() + playerEpisodesButton.setOnClickListener { + toggleEpisodesOverlay(show = true) + } } // init UI try { @@ -1911,10 +1296,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) + rotatedManually = true } } @@ -1935,37 +1320,48 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun playerDimensionsLoaded(width: Int, height: Int) { - isVerticalOrientation = height > width + // PlayerView already set isVerticalOrientation; skip rotation on TV (pillarbox instead). + if (isLayout(TV or EMULATOR)) return + // Skip zero-size events emitted when the player transitions to STATE_IDLE, + // acting on them would reset auto-detected orientation to landscape. + if (width <= 0 || height <= 0) return 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 toggleEpisodesOverlay(show: Boolean) { + if (show && !isShowingEpisodeOverlay) { + previousPlayStatus = player.getIsPlaying() + player.handleEvent(CSPlayerEvent.Pause) + showEpisodesOverlay() + isShowingEpisodeOverlay = true + animateEpisodesOverlay(true) + } else if (isShowingEpisodeOverlay) { + if (previousPlayStatus) player.handleEvent(CSPlayerEvent.Play) + isShowingEpisodeOverlay = false + animateEpisodesOverlay(false) } } - private fun setRemainingTimeCounter(showRemaining: Boolean) { - durationMode = showRemaining - playerBinding?.exoDuration?.isInvisible = showRemaining - playerBinding?.timeLeft?.isVisible = showRemaining - } + private fun animateEpisodesOverlay(show: Boolean) { + playerBinding?.playerEpisodeOverlay?.let { overlay -> + overlay.animate().cancel() + (overlay.parent as? ViewGroup)?.layoutTransition = null // Disable layout transitions - 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 + val offset = 50 * overlay.resources.displayMetrics.density + + overlay.translationX = if (show) offset else 0f + playerBinding?.playerEpisodeOverlay?.isVisible = true + + overlay.animate() + .translationX(if (show) 0f else offset) + .alpha(if (show) 1f else 0f) + .setDuration(300) + .setInterpolator(AccelerateDecelerateInterpolator()).withEndAction { + if (!show) { + playerBinding?.playerEpisodeOverlay?.isGone = true + } + } + .start() } } } 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 be9d01bbe..17bef3ec0 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 @@ -38,15 +38,16 @@ import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.common.util.Util import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerNotificationManager import androidx.media3.ui.PlayerNotificationManager.EXTRA_INSTANCE_ID import androidx.media3.ui.PlayerNotificationManager.MediaDescriptionAdapter import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId @@ -73,42 +74,56 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup 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.LinkSource import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.result.ACTION_CLICK_DEFAULT +import com.lagradost.cloudstream3.ui.result.EpisodeAdapter +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo +import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.SyncViewModel +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF +import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.AppContextUtils.html 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.DataStoreHelper -import com.lagradost.cloudstream3.utils.EpisodeSkip +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.languages 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.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -116,21 +131,29 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.Serializable import java.util.Calendar -import kotlin.math.abs +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean - -@UnstableApi +@OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { companion object { const val NOTIFICATION_ID = 2326 const val CHANNEL_ID = 7340 const val STOP_ACTION = "stopcs3" - private var lastUsedGenerator: IGenerator? = null - fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle { + private val generators = ConcurrentHashMap>() + fun newInstance( + generator: VideoGenerator<*>, + index: Int, + syncData: HashMap? = null + ): Bundle { Log.i(TAG, "newInstance = $syncData") - lastUsedGenerator = generator + val uuid = UUID.randomUUID().toString() + generators[uuid] = generator return Bundle().apply { + putString("uuid", uuid) + putInt("index", index) if (syncData != null) putSerializable("syncData", syncData) } } @@ -141,54 +164,46 @@ class GeneratorPlayer : FullScreenPlayer() { } - private var titleRez = 3 private var limitTitle = 0 + private var showTitle = false + private var showName = false + private var showResolution = false + private var showMediaInfo = false private lateinit var viewModel: PlayerGeneratorViewModel //by activityViewModels() private lateinit var sync: SyncViewModel - private var currentLinks: Set> = setOf() - private var currentSubs: Set = setOf() private var currentSelectedLink: Pair? = null private var currentSelectedSubtitles: SubtitleData? = null - private var currentMeta: Any? = null - private var nextMeta: Any? = null - private var isActive: Boolean = false + private val currentMeta: Any? get() = viewModel.state.generatorState?.meta + private val nextMeta: Any? get() = viewModel.state.generatorState?.nextMeta + + private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) private var isNextEpisode: Boolean = false // this is used to reset the watch time 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 - } - - 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 + private val allMeta: List? + get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() + ?.map { episode -> + // Refresh all the episodes watch duration + getViewPos(episode.id)?.let { data -> + episode.copy(position = data.position, duration = data.duration) + } ?: episode } - if (subtitleLanguage639 != null) { - setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguage639) - preferredAutoSelectSubtitles = subtitleLanguage639 + private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean { + // If subtitle is changed and user initiated -> Save the language + if (subtitle != currentSelectedSubtitles && userInitiated) { + val subtitleLanguageTagIETF = if (subtitle == null) { + "" // -> No Subtitles + } else { + subtitle.getIETF_tag() + } + + if (subtitleLanguageTagIETF != null) { + Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") + setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) + preferredAutoSelectSubtitles = subtitleLanguageTagIETF } } @@ -206,10 +221,11 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerTracksBtt?.isVisible = tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1 // Only set the preferred language if it is available. - // Otherwise it may give some users audio track init failed! + // Otherwise, it may give some users audio track init failed! if (tracks.allAudioTracks.any { it.language == preferredAudioTrackLanguage }) { player.setPreferredAudioTrack(preferredAudioTrackLanguage) } + updatePlayerInfo() } override fun playerStatusChanged() { @@ -220,11 +236,11 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun noSubtitles(): Boolean { - return setSubtitles(null) + return setSubtitles(null, true) } private fun getPos(): Long { - val durPos = DataStoreHelper.getViewPos(viewModel.getId()) ?: return 0L + val durPos = getViewPos(viewModel.state.generatorState?.id) ?: return 0L if (durPos.duration == 0L) return 0L if (durPos.position * 100L / durPos.duration > 95L) { return 0L @@ -254,21 +270,15 @@ class GeneratorPlayer : FullScreenPlayer() { ): PendingIntent { val intent: Intent = Intent(action).setPackage(context.packageName) intent.putExtra(EXTRA_INSTANCE_ID, instanceId) - val pendingFlags = if (Util.SDK_INT >= 23) { + val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } + } else PendingIntent.FLAG_UPDATE_CURRENT return PendingIntent.getBroadcast(context, instanceId, intent, pendingFlags) } - @OptIn(UnstableApi::class) - @UnstableApi private var cachedPlayerNotificationManager: PlayerNotificationManager? = null - @OptIn(UnstableApi::class) - @UnstableApi private fun getMediaNotification(context: Context): PlayerNotificationManager { val cache = cachedPlayerNotificationManager if (cache != null) return cache @@ -345,16 +355,13 @@ class GeneratorPlayer : FullScreenPlayer() { } // retry several times with a preview in case the preview generator is slow - for (i in 0..10) { + repeat(10) { val preview = this@GeneratorPlayer.player.getPreview(0.5f) - if (preview == null) { - delay(1000L) - continue + if (preview != null) { + callback.onBitmap(preview) + return@repeat } - callback.onBitmap( - preview - ) - break + delay(1000L) } } @@ -370,6 +377,7 @@ class GeneratorPlayer : FullScreenPlayer() { return mutableMapOf( STOP_ACTION to NotificationCompat.Action( R.drawable.baseline_stop_24, + @SuppressLint("PrivateResource") context.getString(androidx.media3.ui.R.string.exo_controls_stop_description), createBroadcastIntent(STOP_ACTION, context, instanceId) ) @@ -383,9 +391,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onCustomAction(player: Player, action: String, intent: Intent) { when (action) { STOP_ACTION -> { - exitFullscreen() - this@GeneratorPlayer.player.release() - activity?.popCurrentPage() + exitPlayer() } } } @@ -485,9 +491,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun loadLink(link: Pair?, sameEpisode: Boolean) { + private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { if (link == null) return - + isPlayerActive.set(true) // manage UI binding?.playerLoadingOverlay?.isVisible = false val isTorrent = @@ -495,15 +501,15 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.downloadHeader?.isVisible = false playerBinding?.downloadHeaderToggle?.isVisible = isTorrent + if (!isLayout(PHONE)) { + playerBinding?.downloadBothHeader?.isVisible = isTorrent + } showDownloadProgress(DownloadEvent(0, 0, 0, null)) uiReset() currentSelectedLink = link - currentMeta = viewModel.getMeta() - nextMeta = viewModel.getNextMeta() // setEpisodes(viewModel.getAllMeta() ?: emptyList()) - isActive = true setPlayerDimen(null) setTitle() if (!sameEpisode) @@ -513,6 +519,7 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link + val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -521,43 +528,18 @@ class GeneratorPlayer : FullScreenPlayer() { startPosition = if (sameEpisode) null else { if (isNextEpisode) 0L else getPos() }, - currentSubs, + subtitles, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - currentSubs, settings = true, downloads = true + subtitles, settings = true, downloads = true ), - preview = isFullScreenPlayer + preview = true ) } - if (!sameEpisode) - 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> { - return currentLinks.sortedBy { - -getLinkPriority(qualityProfile, it) + if (!sameEpisode) { + player.addTimeStamps(emptyList()) // clear stamps + // Resets subtitle delay, as we watch some other content + player.setSubtitleOffset(0) } } @@ -595,7 +577,7 @@ class GeneratorPlayer : FullScreenPlayer() { if (entry.lang.isBlank() || !withLanguage) { return entry.name } - val language = fromTwoLettersToLanguage(entry.lang.trim()) ?: entry.lang + val language = fromTagToLanguageName(entry.lang.trim()) ?: entry.lang return "$language ${entry.name}" } @@ -605,15 +587,15 @@ class GeneratorPlayer : FullScreenPlayer() { val providers = subsProviders.toList() val isSingleProvider = subsProviders.size == 1 - val dialog = Dialog(context, R.style.AlertDialogCustomBlack) + val dialog = Dialog(context, R.style.DialogFullscreenPlayer) val binding = DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false) dialog.setContentView(binding.root) + fixSystemBarsPadding(binding.root) var currentSubtitles: List = emptyList() var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null - val layout = R.layout.sort_bottom_single_choice_double_text val arrayAdapter = object : ArrayAdapter(dialog.context, layout) { @@ -639,7 +621,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) @@ -652,9 +633,10 @@ class GeneratorPlayer : FullScreenPlayer() { mainTextView?.text = item?.let { getName(it, false) } val language = - item?.let { fromTwoLettersToLanguage(it.lang.trim()) ?: it.lang } ?: "" + item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" + @SuppressLint("SetTextI18n") secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) @@ -674,7 +656,7 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } - var currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() + var currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() fun setSubtitlesList(list: List) { @@ -739,7 +721,7 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, - lang = currentLanguageTwoLetters.ifBlank { null }, + lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) @@ -787,15 +769,22 @@ class GeneratorPlayer : FullScreenPlayer() { }) binding.searchFilter.setOnClickListener { view -> - val lang639_1 = languages.map { it.ISO_639_1 } + val languagesTagName = + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + val (langTagsIETF, langNames) = languagesTagName.unzip() + activity?.showDialog( - languages.map { it.languageName }, - lang639_1.indexOf(currentLanguageTwoLetters), + langNames, + langTagsIETF.indexOf(currentLanguageTagIETF), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, { }) { index -> - currentLanguageTwoLetters = lang639_1[index] + currentLanguageTagIETF = langTagsIETF[index] binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } @@ -818,7 +807,7 @@ class GeneratorPlayer : FullScreenPlayer() { origin = resource.origin, mimeType = resource.url.toSubtitleMimeType(), headers = currentSubtitle.headers, - currentSubtitle.lang + languageCode = currentSubtitle.lang ) } if (subtitles.isEmpty()) { @@ -854,7 +843,6 @@ class GeneratorPlayer : FullScreenPlayer() { //dialog.subtitles_search_year?.setText(currentTempMeta.year) } - @OptIn(UnstableApi::class) private fun openSubPicker() { try { subsPathPicker.launch( @@ -880,22 +868,21 @@ class GeneratorPlayer : FullScreenPlayer() { vararg subtitleData: SubtitleData ) { if (subtitleData.isEmpty()) return - val selectedSubtitle = subtitleData.first() val ctx = context ?: return - - val subs = currentSubs + subtitleData + val selectedSubtitle = subtitleData.first() + viewModel.addSubtitles(subtitleData.toSet()) // this is used instead of observe(viewModel._currentSubs), because observe is too slow - player.setActiveSubtitles(subs) + player.setActiveSubtitles(viewModel.state.subtitles) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) - setSubtitles(selectedSubtitle) - viewModel.addSubtitles(subtitleData.toSet()) + setSubtitles(selectedSubtitle, false) selectSourceDialog?.dismissSafe() + selectSourceDialog = null showToast( String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), @@ -909,7 +896,7 @@ class GeneratorPlayer : FullScreenPlayer() { safe { // It lies, it can be null if file manager quits. if (uri == null) return@safe - val ctx = context ?: AcraApplication.context ?: return@safe + val ctx = context ?: CloudStreamApp.context ?: return@safe // RW perms for the path ctx.contentResolver.takePersistableUriPermission( uri, @@ -936,10 +923,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private var selectSourceDialog: Dialog? = null -// var selectTracksDialog: AlertDialog? = null - - /** Will toast both when an error is found and when a subtitle is selected, * so only use from a user click and not a background process */ private fun addFirstSub(query: SubtitleSearch) = @@ -995,7 +978,7 @@ class GeneratorPlayer : FullScreenPlayer() { } // checks for both a race condition and if any of the subs generated is new - if (this.isActive && !currentSubs.containsAll(subtitles) && !hasSelectASubtitle) { + if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) { hasSelectASubtitle = true runOnMainThread { addAndSelectSubtitles(*subtitles.toTypedArray()) @@ -1018,13 +1001,14 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - val currentSubtitles = sortSubs(currentSubs) + val currentSubtitles = sortSubs(viewModel.state.subtitles) - val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) + val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) val binding = PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) sourceDialog.setContentView(binding.root) + fixSystemBarsPadding(binding.root) selectSourceDialog = sourceDialog sourceDialog.show() @@ -1045,7 +1029,9 @@ class GeneratorPlayer : FullScreenPlayer() { binding.subtitleSettingsBtt.setOnClickListener { safe { - SubtitlesFragment().show(this.parentFragmentManager, "SubtitleSettings") + val subtitlesFragment = SubtitlesFragment() + subtitlesFragment.systemBarsAddPadding = true + subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") } } @@ -1057,7 +1043,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { - val currentLoadResponse = viewModel.getLoadResponse() + val currentLoadResponse = viewModel.state.generatorState?.response val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null @@ -1069,6 +1055,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) + selectSourceDialog = null openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } @@ -1079,7 +1066,7 @@ class GeneratorPlayer : FullScreenPlayer() { val metadata = getMetaData() val queryName = metadata.name ?: currentLoadResponse?.name if (queryName != null) { - val currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() + val currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() val loadFromFirstSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView @@ -1089,6 +1076,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromFirstSubsFooter.setOnClickListener { sourceDialog.dismissSafe(activity) + selectSourceDialog = null showToast(R.string.loading) addFirstSub( SubtitleSearch( @@ -1099,7 +1087,7 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = currentLoadResponse?.getAniListId()?.toInt(), epNumber = metadata.episode, seasonNumber = metadata.season, - lang = currentLanguageTwoLetters.ifBlank { null }, + lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) ) @@ -1113,7 +1101,7 @@ class GeneratorPlayer : FullScreenPlayer() { var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { - sortedUrls = sortLinks(qualityProfile) + sortedUrls = viewModel.state.sortLinks(qualityProfile) if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true @@ -1198,7 +1186,7 @@ class GeneratorPlayer : FullScreenPlayer() { subsOptionsArrayAdapter.clear() val subtitleOptions = - subtitlesGrouped.entries.toList() + subtitlesGroupedList .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> val nameSuffix = subtitle.nameSuffix.html() nameSuffix.ifBlank { @@ -1264,6 +1252,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) + this.selectSourceDialog = null } fun setProfileName(profile: Int) { @@ -1277,16 +1266,28 @@ class GeneratorPlayer : FullScreenPlayer() { binding.profilesClickSettings.setOnClickListener { val activity = activity ?: return@setOnClickListener - QualityProfileDialog( + val dialog = QualityProfileDialog( activity, - R.style.AlertDialogCustomBlack, - currentLinks.mapNotNull { it.first }, + R.style.DialogFullscreenPlayer, + viewModel.state.links.mapNotNull { + it.first?.let { extractorLink -> + LinkSource( + extractorLink + ) + } + }, currentQualityProfile ) { profile -> currentQualityProfile = profile.id setProfileName(profile.id) - refreshLinks(profile.id) - }.show() + } + + dialog.setOnDismissListener { + viewModel.state.clearSortedLinksCache() + refreshLinks(currentQualityProfile) + } + + dialog.show() } binding.subtitlesEncodingFormat.apply { @@ -1314,6 +1315,7 @@ class GeneratorPlayer : FullScreenPlayer() { shouldDismiss = false sourceDialog.dismissSafe(activity) + selectSourceDialog = null val index = prefValues.indexOf(currentPrefMedia) activity?.showDialog( @@ -1334,18 +1336,15 @@ class GeneratorPlayer : FullScreenPlayer() { } binding.applyBtt.setOnClickListener { - var init = false - if (sourceIndex != startSource) { - init = true - } + var init = sourceIndex != startSource if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { - init = init || if (subtitleGroupIndex <= 0) { + init = init or if (subtitleGroupIndex <= 0) { noSubtitles() } else { subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( subtitleOptionIndex )?.let { - setSubtitles(it) + setSubtitles(it, true) } ?: false } } @@ -1355,6 +1354,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) + selectSourceDialog = null } } } catch (e: Exception) { @@ -1377,11 +1377,14 @@ class GeneratorPlayer : FullScreenPlayer() { val currentAudioTracks = tracks.allAudioTracks val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) - val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) + val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) + this.selectTrackDialog = trackDialog trackDialog.setContentView(binding.root) trackDialog.show() -// selectTracksDialog = tracksDialog + fixSystemBarsPadding(binding.root) + + // selectTracksDialog = tracksDialog val videosList = binding.videoTracksList val audioList = binding.autoTracksList @@ -1424,29 +1427,56 @@ class GeneratorPlayer : FullScreenPlayer() { trackDialog.setOnDismissListener { dismiss() -// selectTracksDialog = null + // selectTracksDialog = null } - var audioIndexStart = currentAudioTracks.indexOf(tracks.currentAudioTrack).takeIf { - it != -1 - } ?: currentVideoTracks.indexOfFirst { - tracks.currentAudioTrack?.id == it.id - } + var audioIndexStart = currentAudioTracks.indexOfFirst { track -> + track.id == tracks.currentAudioTrack?.id && + track.formatIndex == tracks.currentAudioTrack?.formatIndex + }.coerceAtLeast(0) val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - audioArrayAdapter.addAll(currentAudioTracks.mapIndexed { index, format -> - when { - format.label != null && format.language != null -> - "${format.label} - [${fromTwoLettersToLanguage(format.language) ?: format.language}]" + audioArrayAdapter.addAll( + currentAudioTracks.mapIndexed { _, track -> + + val language = ( + track.language?.trim()?.let { raw -> + fromTagToLanguageName(raw) + ?: fromTagToLanguageName( + raw.replace('_', '-').substringBefore('-').lowercase() + ) + ?: raw + } + ?: track.label + ?: "Audio" + ).replaceFirstChar { it.uppercaseChar() } + + val codec = audioCodecName(track.sampleMimeType) + + val channelCount = track.channelCount + + val channels = when { + // May be below 1 or null when unknown + channelCount == null || channelCount <= 0 -> "" + channelCount == 1 -> "Mono" + channelCount == 2 -> "Stereo" + channelCount == 6 -> "5.1" + channelCount == 8 -> "7.1" + else -> "${channelCount}ch" + } + + listOfNotNull( + language.takeIf { it.isNotBlank() } + ?.replaceFirstChar { it.uppercaseChar() }, + channels.takeIf { it.isNotBlank() }, + codec.takeIf { it.isNotBlank() }?.uppercase() + ).joinToString(" • ") + - else -> format.label - ?: format.language?.let { fromTwoLettersToLanguage(it) } - ?: format.language - ?: index.toString() } - }) + ) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -1461,12 +1491,15 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( - currentTrack?.language, currentTrack?.id + currentTrack?.language, + currentTrack?.id, + currentTrack?.formatIndex, ) val currentVideo = currentVideoTracks.getOrNull(videoIndex) @@ -1475,8 +1508,8 @@ class GeneratorPlayer : FullScreenPlayer() { if (width != NO_VALUE && height != NO_VALUE) { player.setMaxVideoSize(width, height, currentVideo?.id) } - trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } } } catch (e: Exception) { @@ -1484,7 +1517,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerError(exception: Throwable) { val currentUrl = currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" @@ -1514,35 +1546,94 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun startPlayer() { - if (isActive) return // we don't want double load when you skip loading + // We don't want double load when you skip loading + if (isPlayerActive.get()) { + return + } - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } + // Atomic operation to prevent double loading + if (!isPlayerActive.compareAndSet(false, true)) { + return + } loadLink(links.first(), false) + showPlayerMetadata() + } + + private fun showPlayerMetadata() { + val overlay = playerBinding?.playerMetadataScrim ?: return + + val titleView = overlay.findViewById(R.id.player_movie_title) + val logoView = overlay.findViewById(R.id.player_movie_logo) + val metaView = overlay.findViewById(R.id.player_movie_meta) + val descView = overlay.findViewById(R.id.player_movie_overview) + + val load = viewModel.state.generatorState?.response ?: return + val episode = currentMeta as? ResultEpisode + titleView.text = load.name + + bindLogo( + url = load.logoUrl, + headers = load.posterHeaders, + titleView = titleView, + logoView = logoView + ) + + val meta = arrayOf( + load.tags?.takeIf { it.isNotEmpty() }?.joinToString(", "), + load.year?.toString(), + if (!load.type.isMovieType()) + context?.getShortSeasonText( + episode = episode?.episode, + season = episode?.season + ) + else null, + load.score?.let { "⭐ $it" } + ).filterNotNull() + .joinToString(" • ") + + metaView.text = meta + metaView.isVisible = meta.isNotBlank() + + + val description = load.plot + + if (!description.isNullOrBlank()) { + descView.isVisible = true + descView.text = description + } else { + descView.isVisible = false + + } } override fun nextEpisode() { - isNextEpisode = true - player.release() - viewModel.loadLinksNext() + if (viewModel.hasNextEpisode() == true) { + isNextEpisode = true + releasePlayer() + viewModel.loadLinksNext() + } } override fun prevEpisode() { - isNextEpisode = true - player.release() - viewModel.loadLinksPrev() + if (viewModel.hasPrevEpisode() == true) { + isNextEpisode = true + releasePlayer() + viewModel.loadLinksPrev() + } } override fun hasNextMirror(): Boolean { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -1586,49 +1677,15 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadStamps(duration) } - viewModel.getId()?.let { - DataStoreHelper.setViewPos(it, position, duration) - } - val percentage = position * 100L / duration - val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE - val resumeMeta = if (nextEp) nextMeta else currentMeta - if (resumeMeta == null && nextEp) { - // remove last watched as it is the last episode and you have watched too much - when (val newMeta = currentMeta) { - is ResultEpisode -> { - DataStoreHelper.removeLastWatched(newMeta.parentId) - } - - is ExtractorUri -> { - DataStoreHelper.removeLastWatched(newMeta.parentId) - } - } - } else { - // save resume - when (resumeMeta) { - is ResultEpisode -> { - DataStoreHelper.setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = false - ) - } - - is ExtractorUri -> { - DataStoreHelper.setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = true - ) - } - } - } + DataStoreHelper.setViewPosAndResume( + viewModel.state.generatorState?.id, + position, + duration, + currentMeta, + nextMeta + ) var isOpVisible = false when (val meta = currentMeta) { @@ -1657,8 +1714,12 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerSkipEpisode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true - else -> - playerBinding?.playerGoForwardRoot?.isVisible = viewModel.hasNextEpisode() == true + else -> { + val hasNextEpisode = viewModel.hasNextEpisode() == true + playerBinding?.playerGoForward?.isVisible = hasNextEpisode + playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode + } + } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { @@ -1670,33 +1731,28 @@ class GeneratorPlayer : FullScreenPlayer() { subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null - val lang = fromTwoLettersToLanguage(langCode) ?: return null if (downloads) { - return subtitles.firstOrNull { sub -> - (sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString( - R.string.default_subtitles - )) - } + sortSubs(subtitles).firstOrNull { + it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( + langCode + ) + }?.let { return it } } - sortSubs(subtitles).firstOrNull { sub -> - val t = sub.name.replace(Regex("[^A-Za-z]"), " ").trim() - (settings) && t == lang || t.startsWith(lang) || t == langCode - }?.let { sub -> - return sub - } + if (!settings) return null - return null + return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } private fun autoSelectFromSettings(): Boolean { - // auto select subtitle based of settings + // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - if (current != null) { - if (setSubtitles(current)) { + // Only use the player preferred subtitle if it matches the available language + if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { + if (setSubtitles(current, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1704,9 +1760,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } else if (!langCode.isNullOrEmpty()) { getAutoSelectSubtitle( - currentSubs, settings = true, downloads = false + viewModel.state.subtitles, settings = true, downloads = false )?.let { sub -> - if (setSubtitles(sub)) { + if (setSubtitles(sub, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1718,20 +1774,20 @@ class GeneratorPlayer : FullScreenPlayer() { return false } - private fun autoSelectFromDownloads(): Boolean { - if (player.getCurrentPreferredSubtitle() == null) { - getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> - context?.let { ctx -> - if (setSubtitles(sub)) { - player.saveData() - player.reloadPlayer(ctx) - player.handleEvent(CSPlayerEvent.Play) - return true - } - } - } + private fun autoSelectFromDownloads() { + if (player.getCurrentPreferredSubtitle() != null) { + return } - return false + val sub = + getAutoSelectSubtitle(viewModel.state.subtitles, settings = false, downloads = true) + ?: return + val ctx = context ?: return + if (!setSubtitles(sub, false)) { + return + } + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) } private fun autoSelectSubtitles() { @@ -1743,6 +1799,14 @@ class GeneratorPlayer : FullScreenPlayer() { } } + private fun getHeaderName(): String? { + return when (val meta = currentMeta) { + is ResultEpisode -> meta.headerName + is ExtractorUri -> meta.headerName + else -> null + } + } + private fun getPlayerVideoTitle(): String { var headerName: String? = null var subName: String? = null @@ -1789,8 +1853,6 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } - - @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1809,29 +1871,105 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false playerBinding?.playerVideoTitle?.text = playerVideoTitle + playerBinding?.offlinePin?.isVisible = viewModel.generator is DownloadFileGenerator } - @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { - val extra = if (widthHeight != null) { - val (width, height) = widthHeight - "- ${width}x${height}" - } else { - "" + val resolution = widthHeight?.let { "${it.first}x${it.second}" } + val name = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name + val title = getHeaderName() + + val result = listOfNotNull( + title?.takeIf { showTitle && it.isNotBlank() }, + name?.takeIf { showName && it.isNotBlank() }, + resolution?.takeIf { showResolution && it.isNotBlank() }, + ).joinToString(" - ") + + playerBinding?.playerVideoTitleRez?.apply { + text = result + isVisible = result.isNotBlank() } + } - val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" - val title = when (titleRez) { - 0 -> "" - 1 -> extra - 2 -> source - 3 -> "$source $extra" + private fun videoCodecName(mime: String?): String? { + val m = mime?.lowercase() ?: return null + return when { + m.contains("avc") || m.contains("h264") -> "AVC" + m.contains("hevc") || m.contains("h265") -> "HEVC" + m.contains("av1") -> "AV1" + m.contains("vp9") -> "VP9" + m.contains("vp8") -> "VP8" + "/" in m -> m.substringAfter("/").uppercase() + else -> m.uppercase() + } + } + + private fun audioCodecName(mime: String?): String { + val m = mime?.lowercase()?.trim().orEmpty() + if (m.isBlank()) return "" + return when { + m.contains("eac3-joc") -> "Dolby Atmos" + m.contains("truehd") -> "TrueHD" + m.contains("eac3") -> "E-AC3" + m.contains("ac-3") || m.contains("ac3") -> "AC3" + m.contains("aac") || m.contains("mp4a") -> "AAC" + m.contains("opus") -> "Opus" + m.contains("vorbis") -> "Vorbis" + m.contains("mp3") -> "MP3" + m.contains("flac") -> "FLAC" + m.contains("dts") -> "DTS" + m.contains("pcm") -> "PCM" + m.contains("alac") -> "ALAC" + m.contains("amr") -> "AMR" + m.contains("/") -> m.substringAfter("/").uppercase().takeIf { it.isNotBlank() } ?: "" else -> "" } - playerBinding?.playerVideoTitleRez?.apply { - text = title - isVisible = title.isNotBlank() + } + + private fun updatePlayerInfo() { + val tracks = player.getVideoTracks() + + val videoTrack = tracks.currentVideoTrack + val audioTrack = tracks.currentAudioTrack + + val ctx = context ?: return + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + showMediaInfo = prefs.getBoolean(ctx.getString(R.string.show_media_info_key), false) + + val videoCodec = videoCodecName(videoTrack?.sampleMimeType) + val audioCodec = audioCodecName(audioTrack?.sampleMimeType) + val languageName = fromTagToLanguageName(audioTrack?.language) + val label = audioTrack?.label + + val channelCount = audioTrack?.channelCount + + val channels = when { + // May be below 1 or null when unknown + channelCount == null || channelCount <= 0 -> "" + channelCount == 1 -> "Mono" + channelCount == 2 -> "Stereo" + channelCount == 6 -> "5.1" + channelCount == 8 -> "7.1" + else -> "${channelCount}ch" + } + + val language = languageName?.takeIf { it.isNotBlank() }?.let { lang -> + label?.takeIf { it.isNotBlank() && !it.equals(lang, true) } + ?.let { lang } + ?: lang + } ?: label?.takeIf { it.isNotBlank() } + + val stats = arrayOf( + videoCodec, + language, + channels, + audioCodec + ).filter { !it.isNullOrBlank() }.joinToString(" • ") + + playerBinding?.playerVideoInfo?.apply { + text = stats + isVisible = showMediaInfo && stats.isNotBlank() } } @@ -1847,31 +1985,13 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onCreateView( - 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 - - viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] - sync = ViewModelProvider(this)[SyncViewModel::class.java] - - viewModel.attachGenerator(lastUsedGenerator) - 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() - } - - var timestampShowState = false + /** + * This is used instead of layout-television to follow the + * settings and some TV devices are not classified as TV + * for some reason. + */ + override fun pickLayout(): Int = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -1892,6 +2012,12 @@ class GeneratorPlayer : FullScreenPlayer() { skipAnimator?.cancel() isVisible = true + /** Focus instantly to make the focus color appear instantly */ + if (show && !isShowing) { + // Automatically request focus if the menu is not opened + playerBinding?.skipChapterButton?.requestFocus() + } + // just in case val lay = layoutParams lay.width = from @@ -1900,12 +2026,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 { + if (!show) { playerBinding?.skipChapterButton?.isVisible = false if (!isShowing) { // Automatically return focus to play pause @@ -1925,11 +2046,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + override fun onTimestampSkipped(timestamp: VideoSkipStamp) { displayTimeStamp(false) } - override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + override fun onTimestamp(timestamp: VideoSkipStamp?) { if (timestamp != null) { playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) @@ -1943,26 +2064,143 @@ class GeneratorPlayer : FullScreenPlayer() { } } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - var langFilterList = listOf() - var filterSubByLang = false + override fun isThereEpisodes(): Boolean { + // Checks if there is a second episode of type ResultEpisode + // => There exists more than 1 episode, and they are all ResultEpisode + return viewModel.state.generatorState?.allMeta?.getOrNull(1) as? ResultEpisode != null + } + + override fun showEpisodesOverlay() { + try { + playerBinding?.apply { + playerEpisodeList.setRecycledViewPool(EpisodeAdapter.sharedPool) + playerEpisodeList.adapter = EpisodeAdapter( + false, + { episodeClick -> + if (episodeClick.action == ACTION_CLICK_DEFAULT) { + isNextEpisode = false + releasePlayer() + playerEpisodeOverlay.isGone = true + episodeClick.position?.let { viewModel.loadThisEpisode(it) } + } + }, + { downloadClickEvent -> + DownloadButtonSetup.handleDownloadClick(downloadClickEvent) + } + ) + playerEpisodeList.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + val episodes = allMeta ?: emptyList() + (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) + + // Scroll to current episode + viewModel.state.generatorState?.index?.let { index -> + playerEpisodeList.scrollToPosition(index) + // Ensure focus on tv + if (isLayout(TV)) { + playerEpisodeList.post { + val viewHolder = + playerEpisodeList.findViewHolderForAdapterPosition(index) + viewHolder?.itemView?.requestFocus() + viewHolder?.itemView?.let { itemView -> + itemView.isFocusableInTouchMode = true + itemView.requestFocus() + } + } + } + } + + // update overlay season title + var lastTopIndex = -1 + playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val layoutManager = + recyclerView.layoutManager as? LinearLayoutManager ?: return + val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() + if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { + @Suppress("AssignedValueIsNeverRead") + lastTopIndex = topIndex + val topItem = episodes.getOrNull(topIndex) + topItem?.let { + playerEpisodeOverlayTitle.setText( + ResultViewModel2.seasonToTxt( + topItem.seasonData, + topItem.seasonIndex + ) + ) + } + } + } + }) + } + } catch (e: Exception) { + logError(e) + } + } + + @MainThread + fun releasePlayer() { + player.release() + currentSelectedSubtitles = null + currentSelectedLink = null + isPlayerActive.set(false) + binding?.overlayLoadingSkipButton?.isVisible = false + binding?.playerLoadingOverlay?.isVisible = true + uiReset() + } + + fun exitPlayer() { + playerHostView?.exitFullscreen() + player.release() + activity?.popCurrentPage() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt("index", viewModel.episodeIndex) + super.onSaveInstanceState(outState) + } + + override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { + viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] + sync = ViewModelProvider(this)[SyncViewModel::class.java] + + val uuid = savedInstanceState?.getString("uuid") ?: arguments?.getString("uuid") + val index = savedInstanceState?.getInt("index") ?: arguments?.getInt("index") + val generator = generators[uuid] + + unwrapBundle(savedInstanceState) + unwrapBundle(arguments) + + super.onBindingCreated(binding, savedInstanceState) + + // Avoid showing no links found + if (generator == null || index == null) { + exitPlayer() + return + } + viewModel.attachGenerator(generator, index) context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - titleRez = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_rez_key), 3) - limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_key), 0) + showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) + showResolution = + settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) + showMediaInfo = + settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) + limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) updateForcedEncoding(ctx) - - filterSubByLang = + viewModel.filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) - if (filterSubByLang) { + if (viewModel.filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), mutableSetOf("en") ) - langFilterList = langFromPrefMedia?.mapNotNull { - fromTwoLettersToLanguage(it)?.lowercase() ?: return@mapNotNull null + viewModel.langFilterList = langFromPrefMedia?.mapNotNull { + fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } } @@ -1972,20 +2210,25 @@ class GeneratorPlayer : FullScreenPlayer() { sync.updateUserData() - preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1() + preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() - if (currentSelectedLink == null) { + val selectedLink = currentSelectedLink + if (selectedLink == null) { viewModel.loadLinks() + } else { + // Recreated view, so we need to recreate the + loadLink(selectedLink, true) } - binding?.overlayLoadingSkipButton?.setOnClickListener { - startPlayer() + binding.overlayLoadingSkipButton.setOnClickListener { + // Mark as "success" early + viewModel.modifyState { + copy(loading = Resource.Success(Unit)) + } } - binding?.playerLoadingGoBack?.setOnClickListener { - exitFullscreen() - player.release() - activity?.popCurrentPage() + binding.playerLoadingGoBack.setOnClickListener { + exitPlayer() } playerBinding?.downloadHeader?.setOnClickListener { @@ -1998,14 +2241,29 @@ class GeneratorPlayer : FullScreenPlayer() { } } - observe(viewModel.currentStamps) { stamps -> + observe(viewModel.currentStamps) { (stamps, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe player.addTimeStamps(stamps) } - observe(viewModel.loadingLinks) { - when (it) { + observe(viewModel.currentSubtitles) { (subtitles, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + player.setActiveSubtitles(subtitles) + + // If the file is downloaded then do not select auto select the subtitles + // Downloaded subtitles cannot be selected immediately after loading since + // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles + // Resulting in unselecting the downloaded subtitle + if (subtitles.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { + autoSelectSubtitles() + } + } + observe(viewModel.loadingLinks) { (loading, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + + when (loading) { is Resource.Loading -> { - startLoading() + releasePlayer() } is Resource.Success -> { @@ -2017,30 +2275,31 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { - showToast(it.errorString, Toast.LENGTH_LONG) + showToast(loading.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { - currentLinks = it - val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true - val wasGone = binding?.overlayLoadingSkipButton?.isGone == true + observe(viewModel.currentLinks) { (links, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe - binding?.overlayLoadingSkipButton?.apply { + val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true + val wasGone = binding.overlayLoadingSkipButton.isGone + + binding.overlayLoadingSkipButton.apply { isVisible = turnVisible - val value = viewModel.currentLinks.value - if (value.isNullOrEmpty()) { + if (links.isEmpty()) { setText(R.string.skip_loading) } else { - text = "${context.getString(R.string.skip_loading)} (${value.size})" + @SuppressLint("SetTextI18n") + text = "${context.getString(R.string.skip_loading)} (${links.size})" } } safe { - if (currentLinks.any { link -> - getLinkPriority(currentQualityProfile, link) >= + if (!isPlayerActive.get() && viewModel.state.links.any { link -> + getLinkPriority(currentQualityProfile, link.first) >= QualityDataHelper.AUTO_SKIP_PRIORITY } ) { @@ -2049,33 +2308,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - binding?.overlayLoadingSkipButton?.requestFocus() - } - } - - observe(viewModel.currentSubs) { set -> - val setOfSub = mutableSetOf() - if (langFilterList.isNotEmpty() && filterSubByLang) { - Log.i("subfilter", "Filtering subtitle") - langFilterList.forEach { lang -> - Log.i("subfilter", "Lang: $lang") - setOfSub += set.filter { - it.name.contains(lang, ignoreCase = true) || - it.origin != SubtitleOrigin.URL - } - } - currentSubs = setOfSub - } else { - currentSubs = set - } - player.setActiveSubtitles(set) - - // If the file is downloaded then do not select auto select the subtitles - // Downloaded subtitles cannot be selected immediately after loading since - // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles - // Resulting in unselecting the downloaded subtitle - if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { - autoSelectSubtitles() + binding.overlayLoadingSkipButton.requestFocus() } } } @@ -2086,4 +2319,4 @@ inline fun Bundle.getSafeSerializable(key: String): T if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable( key, T::class.java - ) \ No newline at end of file + ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt index 4aaee7bb7..3ab46ce21 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 @@ -25,27 +25,27 @@ val LOADTYPE_CHROMECAST = setOf( val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() -interface IGenerator { - val hasCache: Boolean - val canSkipLoading: Boolean +abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { + override val hasCache = false + override val canSkipLoading = false + override fun getId(index: Int): Int? = id +} - fun hasNext(): Boolean - fun hasPrev(): Boolean - fun next() - fun prev() - fun goto(index: Int) +abstract class VideoGenerator(val videos: List) { + abstract val hasCache: Boolean + abstract val canSkipLoading: Boolean + abstract fun getId(index : Int) : 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 hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex + fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 - /* not safe, must use try catch */ - suspend fun generateLinks( + @Throws + abstract suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int = 0, - isCasting: Boolean = false + offset: Int, + isCasting: Boolean ): Boolean } 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 01f2b1702..034237266 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 @@ -3,30 +3,11 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.graphics.Bitmap import android.util.Rational +import androidx.annotation.AnyThread +import androidx.annotation.MainThread import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink - -enum class PlayerEventType(val value: Int) { - Pause(0), - Play(1), - SeekForward(2), - SeekBack(3), - - SkipCurrentChapter(4), - NextEpisode(5), - PrevEpisode(6), - PlayPauseToggle(7), - ToggleMute(8), - Lock(9), - ToggleHide(10), - ShowSpeed(11), - ShowMirrors(12), - Resize(13), - SearchSubtitlesOnline(14), - SkipOp(15), - Restart(16), -} +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp enum class CSPlayerEvent(val value: Int) { Pause(0), @@ -47,6 +28,7 @@ enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, + IsEnded, } enum class PlayerEventSource { @@ -85,13 +67,13 @@ data class ErrorEvent( /** Event when timestamps appear, null when it should disappear */ data class TimestampInvokedEvent( - val timestamp: EpisodeSkip.SkipStamp, + val timestamp: VideoSkipStamp, 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, + val timestamp: VideoSkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() @@ -181,6 +163,7 @@ interface Track { val id: String? val label: String? val language: String? + val sampleMimeType : String? } data class VideoTrack( @@ -189,19 +172,23 @@ data class VideoTrack( override val language: String?, val width: Int?, val height: Int?, + override val sampleMimeType: String?, ) : Track data class AudioTrack( override val id: String?, override val label: String?, override val language: String?, + override val sampleMimeType: String?, + val channelCount: Int?, + val formatIndex: Int?, ) : Track data class TextTrack( override val id: String?, override val label: String?, override val language: String?, - val mimeType: String?, + override val sampleMimeType: String?, ) : Track @@ -214,8 +201,6 @@ data class CurrentTracks( val allTextTracks: List, ) -class InvalidFileException(msg: String) : Exception(msg) - //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 const val ACTION_MEDIA_CONTROL = "media_control" const val EXTRA_CONTROL_TYPE = "control_type" @@ -237,8 +222,9 @@ interface IPlayer { fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms + @AnyThread fun initCallbacks( - eventHandler: ((PlayerEvent) -> Unit), + @MainThread eventHandler: ((PlayerEvent) -> Unit), /** this is used to request when the player should report back view percentage */ requestedListeningPercentages: List? = null, ) @@ -248,7 +234,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -301,8 +287,8 @@ interface IPlayer { fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) /** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */ - fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null) + fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null) /** Get the current subtitle cues, for use with syncing */ fun getSubtitleCues(): List -} \ No newline at end of file +} 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 4416ce3b9..db06e26e9 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 @@ -40,36 +40,8 @@ class LinkGenerator( private val links: List, private val extract: Boolean = true, private val refererUrl: String? = null, -) : IGenerator { - override val hasCache = false - override val canSkipLoading = true - - override fun getCurrentId(): Int? { - return null - } - - override fun hasNext(): Boolean { - return false - } - - override fun getAll(): List? { - return null - } - - override fun hasPrev(): Boolean { - return false - } - - override fun getCurrent(offset: Int): Any? { - return null - } - - override fun goto(index: Int) {} - - override fun next() {} - - override fun prev() {} - + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, @@ -107,37 +79,8 @@ class LinkGenerator( class MinimalLinkGenerator( private val links: List, private val subs: List, - private val id : Int? = null -) : IGenerator { - override val hasCache = false - override val canSkipLoading = true - - override fun getCurrentId(): Int? { - return id - } - - override fun hasNext(): Boolean { - return false - } - - override fun getAll(): List? { - return null - } - - override fun hasPrev(): Boolean { - return false - } - - override fun getCurrent(offset: Int): Any? { - return null - } - - override fun goto(index: Int) {} - - override fun next() {} - - override fun prev() {} - + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, 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 index eb9f5c249..dcf976612 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -1,10 +1,10 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity -import android.content.ContentUris import android.content.Intent import android.net.Uri import androidx.core.content.ContextCompat.getString +import androidx.navigation.NavOptions import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson @@ -13,15 +13,25 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.safefile.SafeFile object OfflinePlaybackHelper { + /** + * Pop any existing player off the nav back stack before pushing the new one, + * keeping the stack flat (at most one player at a time). This prevents an + * OOM when many files are opened in sequence via DownloadedPlayerActivity. + */ + private val replacePlayerNavOptions = NavOptions.Builder() + .setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false) + .build() + fun playLink(activity: Activity, url: String) { activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( LinkGenerator( listOf( BasicLink(url) - ) - ) - ) + ), id = url.hashCode() + ), 0 + ), + replacePlayerNavOptions ) } @@ -52,8 +62,9 @@ object OfflinePlaybackHelper { links, subs, if (id != -1) id else null, - ) - ) + ), 0 + ), + replacePlayerNavOptions ) return true } @@ -73,12 +84,12 @@ object OfflinePlaybackHelper { 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() + id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode() ) ) - ) - ) + ), 0 + ), + replacePlayerNavOptions ) } } \ 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 023cedd8a..e3c390d50 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 @@ -9,34 +9,188 @@ 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.safe import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority 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.ExtractorLinkType +import com.lagradost.cloudstream3.utils.videoskip.SkipAPI +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.jetbrains.annotations.Contract +import java.util.concurrent.ConcurrentHashMap + +typealias VideoLink = Pair + +data class GeneratorState( + val meta: Any?, + val nextMeta: Any?, + val allMeta: List<*>?, + val response: LoadResponse?, + val index: Int, + val id: Int?, +) + +/** Immutable state of all current links relevant to displaying the video */ +// @MustUseReturnValues +// @Immutable +data class VideoState( + val subtitles: PersistentSet = persistentSetOf(), + val links: PersistentSet = persistentSetOf(), + val stamps: PersistentList = persistentListOf(), + val loading: Resource = Resource.Loading(), + val generatorState: GeneratorState? = null, + val instance: Int, +) { + /** + * This acts as a local cache for sorted links that are not copied over by the copy constructor. + * + * sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation + * */ + private val sortedLinks: ConcurrentHashMap> = ConcurrentHashMap() + + fun clearSortedLinksCache() = sortedLinks.clear() + + // Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result + // It is by all standards, idempotent and by extension also pure as it has no "visible" side effect + /** Returns .links in the sorted order according to the qualityProfile. + * Use .links if order is not needed */ + @Contract(pure = true) + fun sortLinks(qualityProfile: Int): List { + return sortedLinks[qualityProfile] ?: links.sortedBy { link -> + // negative because we want to sort highest quality first + -getLinkPriority(qualityProfile, link.first) + }.also { value -> sortedLinks[qualityProfile] = value } + } + + @Contract(pure = true) + fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item)) + + @Contract(pure = true) + fun add(item: VideoLink): VideoState = copy(links = links.add(item)) + + @Contract(pure = true) + fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item)) + + @JvmName("addSubtitleData") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(subtitles = subtitles.addAll(items)) + + @JvmName("addVideoLink") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(links = links.addAll(items)) + + @JvmName("addVideoSkipStamp") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(stamps = stamps.addAll(items)) + + @Contract(pure = true) + fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item)) + + @JvmName("setSubtitleData") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(subtitles = items.toPersistentSet()) + + @JvmName("setVideoLink") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(links = items.toPersistentSet()) + + @JvmName("setVideoSkipStamp") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(stamps = items.toPersistentList()) +} + +data class VideoLive( + val value: T, + val instance: Int, +) class PlayerGeneratorViewModel : ViewModel() { companion object { const val TAG = "PlayViewGen" } - private var generator: IGenerator? = null + @Volatile + var generator: VideoGenerator<*>? = null - private val _currentLinks = MutableLiveData>>(setOf()) - val currentLinks: LiveData>> = _currentLinks + @Volatile + var episodeIndex: Int = 0 - private val _currentSubs = MutableLiveData>(setOf()) - val currentSubs: LiveData> = _currentSubs + /** + * The state of the video player, only modify it by modifyState to make sure observe is called, + * and avoid concurrency issues. + * + * This value can be used without Synchronized or locking when reading, as all fields are immutable. + * */ + @Volatile + var state = VideoState(instance = 0) + private set - private val _loadingLinks = MutableLiveData>() - val loadingLinks: LiveData> = _loadingLinks + private val _currentLinks = + MutableLiveData>>>(null) + val currentLinks: LiveData>>> = _currentLinks - private val _currentStamps = MutableLiveData>(emptyList()) - val currentStamps: LiveData> = _currentStamps + private val _currentSubtitles = MutableLiveData>>(null) + val currentSubtitles: LiveData>> = _currentSubtitles + + private val _loadingLinks = MutableLiveData>>() + val loadingLinks: LiveData>> = _loadingLinks + + private val _currentStamps = MutableLiveData>>(null) + val currentStamps: LiveData>> = _currentStamps + + /** + * Modifies the `state` variable safely, and with the correct observe behavior. + * + * Synchronized to avoid concurrency issues, and make this operation atomic. + * Otherwise, one update may be lost if they are done in parallel. + * */ + @Synchronized + fun modifyState(op: VideoState.() -> VideoState) { + val oldState = state + state = op.invoke(oldState) + + /** New instance, always push state */ + if (state.instance != oldState.instance) { + _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + _currentStamps.postValue(VideoLive(state.stamps, state.instance)) + _currentLinks.postValue(VideoLive(state.links, state.instance)) + _loadingLinks.postValue(VideoLive(state.loading, state.instance)) + return + } + + /** + * Only post the changed values, this makes sure we do not invoke the "observe" + * + * We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality + * to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged. + * */ + if (state.links !== oldState.links) + _currentLinks.postValue(VideoLive(state.links, state.instance)) + if (state.stamps !== oldState.stamps) + _currentStamps.postValue(VideoLive(state.stamps, state.instance)) + if (state.subtitles !== oldState.subtitles) + _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + + /** Normal equality here as it is not a collection */ + if (state.loading != oldState.loading) + _loadingLinks.postValue(VideoLive(state.loading, state.instance)) + } private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear @@ -52,37 +206,32 @@ class PlayerGeneratorViewModel : ViewModel() { _currentSubtitleYear.postValue(year) } - fun getId(): Int? { - return generator?.getCurrentId() - } - - fun loadLinks(episode: Int) { - generator?.goto(episode) - loadLinks() - } - fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") - if (generator?.hasPrev() == true) { - generator?.prev() + if (generator?.hasPrev(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") - if (generator?.hasNext() == true) { - generator?.next() + if (generator?.hasNext(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun hasNextEpisode(): Boolean? { - return generator?.hasNext() + return generator?.hasNext(episodeIndex) + } + + fun hasPrevEpisode(): Boolean? { + return generator?.hasPrev(episodeIndex) } fun preLoadNextLinks() { - val id = getId() + val id = generator?.getId(episodeIndex) // Do not preload if already loading if (id == currentLoadingEpisodeId) return @@ -92,14 +241,15 @@ class PlayerGeneratorViewModel : ViewModel() { currentJob = viewModelScope.launch { try { - if (generator?.hasCache == true && generator?.hasNext() == true) { + if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) { safeApiCall { generator?.generateLinks( sourceTypes = LOADTYPE_INAPP, clearCache = false, + isCasting = false, callback = {}, subtitleCallback = {}, - offset = 1 + offset = episodeIndex + 1 ) } } @@ -113,119 +263,137 @@ class PlayerGeneratorViewModel : ViewModel() { } } - fun getLoadResponse(): LoadResponse? { - return safe { (generator as? RepoLinkGenerator?)?.page } + fun loadThisEpisode(index: Int) { + episodeIndex = index + loadLinks() } - fun getMeta(): Any? { - return safe { generator?.getCurrent() } + fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { + Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") + generator = newGenerator + episodeIndex = index } - fun getAllMeta(): List? { - return safe { generator?.getAll() } - } - - fun getNextMeta(): Any? { - return safe { - if (generator?.hasNext() == false) return@safe null - generator?.getCurrent(offset = 1) - } - } - - fun attachGenerator(newGenerator: IGenerator?) { - if (generator == null) { - generator = newGenerator - } - } - - private var extraSubtitles : MutableSet = mutableSetOf() - /** * If duplicate nothing will happen * */ - fun addSubtitles(file: Set) = synchronized(extraSubtitles) { - extraSubtitles += file - val current = _currentSubs.value ?: emptySet() - val next = extraSubtitles + current - - // if it is of a different size then we have added distinct items - if (next.size != current.size) { - // Posting will refresh subtitles which will in turn - // make the subs to english if previously unselected - _currentSubs.postValue(next) - } + fun addSubtitles(file: Set) { + val validFile = file.filter(::isValidSubtitle) + if (validFile.isNotEmpty()) + modifyState { + add(validFile) + } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { - //currentStampJob?.cancel() currentStampJob = ioSafe { - val meta = generator?.getCurrent() - val page = (generator as? RepoLinkGenerator?)?.page - if (page != null && meta is ResultEpisode) { - _currentStamps.postValue(listOf()) - _currentStamps.postValue( - EpisodeSkip.getStamps( - page, - meta, - duration, - hasNextEpisode() ?: false - ) - ) + val genState = state.generatorState ?: return@ioSafe + val meta = genState.meta + val page = genState.response + val id = genState.id + if (page == null || meta !is ResultEpisode) { + return@ioSafe } + val stamps = SkipAPI.videoStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + + /** Avoid adding stamps to the wrong video */ + modifyState { + if (id != this.generatorState?.id) { + this + } else { + set(stamps) + } + } + } + } + + var langFilterList = listOf() + var filterSubByLang = false + + fun isValidSubtitle(subtitle: SubtitleData): Boolean { + if (langFilterList.isEmpty() || !filterSubByLang) { + return true + } + + /** Only filter out subtitles fetched online */ + if (subtitle.origin != SubtitleOrigin.URL) { + return true + } + + return langFilterList.any { lang -> + subtitle.originalName.contains(lang, ignoreCase = true) } } fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) { - Log.i(TAG, "loadLinks") + Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex") currentJob?.cancel() + val index = episodeIndex + + // Clear old data and reset the state + modifyState { + VideoState( + loading = Resource.Loading(), + generatorState = generator?.let { gen -> + GeneratorState( + meta = gen.videos.getOrNull(index), + nextMeta = gen.videos.getOrNull(index + 1), + id = gen.getId(index), + response = (gen as? RepoLinkGenerator)?.page, + index = index, + allMeta = gen.videos + ) + }, + instance = instance + 1 + ) + } currentJob = viewModelScope.launchSafe { - // if we load links then we clear the prev loaded links - synchronized(extraSubtitles) { - extraSubtitles.clear() - } - val currentLinks = mutableSetOf>() - val currentSubs = mutableSetOf() - - // clear old data - _currentSubs.postValue(emptySet()) - _currentLinks.postValue(emptySet()) - - // load more data - _loadingLinks.postValue(Resource.Loading()) + // Load more data val loadingState = safeApiCall { generator?.generateLinks( sourceTypes = sourceTypes, clearCache = forceClearCache, - callback = { - synchronized(currentLinks) { - currentLinks.add(it) - // Clone to prevent ConcurrentModificationException - safe { - // Extra safe since .toSet() iterates. - _currentLinks.postValue(currentLinks.toSet()) + callback = { link -> + if (isActive) + modifyState { + add(link) } - } }, - subtitleCallback = { - synchronized(extraSubtitles) { - currentSubs.add(it) - safe { - _currentSubs.postValue(currentSubs + extraSubtitles) + isCasting = false, + offset = index, + subtitleCallback = { link -> + if (isActive && isValidSubtitle(link)) + modifyState { + add(link) } - } }) + Unit } - _loadingLinks.postValue(loadingState) - _currentLinks.postValue(currentLinks) - synchronized(extraSubtitles) { - _currentSubs.postValue(currentSubs + extraSubtitles) + if (!isActive) { + return@launchSafe + } + + /** Only mark as success if we have not skipped loading */ + modifyState { + if (!isActive) { + this + } else { + when (loading) { + is Resource.Loading -> copy(loading = loadingState) + else -> this + } + } } } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt new file mode 100644 index 000000000..1c7086d12 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1220 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +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.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt + +/** + * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a + * [PlayerView]. Keeps these separate from the player-view setup and lifecycle + * code in [PlayerView] itself. + * + * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate + * properties [PlayerView] exposes. + */ +@OptIn(UnstableApi::class) +class PlayerGestureHelper(private val playerView: PlayerView) { + + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } + + private val context: Context get() = playerView.context + + /** Set true by the host when the player occupies the full screen. + * Controls whether hardware volume-key overrides are active (phones/emulators only). */ + var isFullScreen: Boolean = false + + /** Volume state */ + var currentRequestedVolume: Float = 0.0f + var isVolumeLocked: Boolean = false + var hasShownVolumeToast: Boolean = false + private var loudnessEnhancer: LoudnessEnhancer? = null + private var progressBarLeftHideRunnable: Runnable? = null + + /** Brightness state */ + var currentRequestedBrightness: Float = 1.0f + var currentExtraBrightness: Float = 0.0f + var isBrightnessLocked: Boolean = false + var hasShownBrightnessToast: Boolean = false + /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. + * Automatically falls back to window-attribute brightness if the permission is missing. */ + var useTrueSystemBrightness: Boolean = true + /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ + var brightnessOverlay: View? = null + private var progressBarRightHideRunnable: Runnable? = null + + /** Gesture settings (read from prefs in initialize) */ + var swipeVerticalEnabled: Boolean = true + var swipeHorizontalEnabled: Boolean = false + var extraBrightnessEnabled: Boolean = false + var speedupEnabled: Boolean = false + + /** Hold / speed-up */ + val holdHandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + playerView.player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + playerView.callbacks?.onHoldSpeedUp(true) + hasTriggeredSpeedUp = true + } + + enum class TouchAction { Brightness, Volume, Time } + + /** Mirrors the host's lock state; suppresses gesture interactions when true. */ + var isLocked: Boolean = false + + /** Touch tracking */ + var isCurrentTouchValid = false + private set + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + /** Current in-progress swipe action, null when no swipe is active. */ + var currentTouchAction: TouchAction? = null + /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ + var currentLastTouchAction: TouchAction? = null + /** The time in the player when you first click. */ + private var currentTouchStartPlayerTime: Long? = null + /** The system time when you first click. */ + private var currentTouchStartTime: Long? = null + /** Whether the player UI was visible when the current swipe gesture began. */ + var uiShowingBeforeGesture: Boolean = false + + /** Icons */ + private val brightnessIcons = listOf( + R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, + R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, + ) + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) + + /** Double-tap / tap state */ + + /** Whether double-tapping left/right seeks backward/forward. */ + var doubleTapEnabled: Boolean = false + + /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + var doubleTapPauseEnabled: Boolean = false + + /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ + var fastForwardTime: Long = 10_000L + + /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ + private var doubleTapToken = 0 + + /** Number of consecutive taps in the current double-tap window. */ + private var tapCount = 0 + + /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ + var lastTouchEndTime: Long = 0L + + /** Zoom state */ + + /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ + var videoOutline: View? = null + + /** Current zoom+pan matrix, or null when no zoom is active. */ + var zoomMatrix: Matrix? = null + + /** The matrix the zoom will animate to after the user lifts fingers. */ + var desiredMatrix: Matrix? = null + + /** Running snap-back animation, or null. */ + var matrixAnimation: ValueAnimator? = null + + private var scaleGestureDetector: ScaleGestureDetector? = null + + /** Midpoint of the two-finger pan, null when no pan is active. */ + var lastPan: Vector2? = null + + private var overlayLayoutListener: View.OnLayoutChangeListener? = null + + /** Called from [PlayerView.initialize] after views are bound. */ + fun initialize() { + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) + extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { + } + + // Inject the brightness overlay into the ExoPlayer content frame so it sits + // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. + safe { + val pkg = context.packageName + @SuppressLint("DiscouragedApi") + val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) + val contentFrame = playerView.exoPlayerView?.findViewById(contentId) + if (contentFrame != null) { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + brightnessOverlay = LayoutInflater.from(context) + .inflate(R.layout.extra_brightness_overlay, contentFrame, false) + contentFrame.addView(brightnessOverlay) + } + } + + setupTouchGestures() + } + + /** Called from [PlayerView.release]. */ + fun release() { + safe { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + } + brightnessOverlay = null + loudnessEnhancer?.release() + loudnessEnhancer = null + holdHandler.removeCallbacksAndMessages(null) + clearZoomState() + releaseOverlayLayoutListener() + } + + /** Key-event listener */ + + /** + * Registers the basic volume-key listener on [keyEventListener]. + * Called from [PlayerView.initialize] and from the host fragment's onResume. + */ + fun setupKeyEventListener() { + keyEventListener = { (event, _) -> + if (event != null && event.action == KeyEvent.ACTION_DOWN) + handleVolumeKey(event.keyCode) + else false + } + } + + /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ + fun releaseKeyEventListener() { + keyEventListener = null + } + + /** Speed-up */ + + fun showOrHideSpeedUp(show: Boolean) { + playerView.playerSpeedupButton?.let { btn -> + btn.clearAnimation() + btn.alpha = if (show) 0f else 1f + btn.isVisible = show + btn.animate() + .alpha(if (show) 1f else 0f) + .setDuration(200L) + .withEndAction { if (!show) btn.isVisible = false } + .start() + } + } + + /** Volume helpers */ + + /** + * Syncs [currentRequestedVolume] with the current system stream volume. + * + * This is here to make returning to the player less jarring, if we change the volume outside + * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning + * however that is the cost of correctness. + */ + fun verifyVolume() { + ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (cur < max || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = cur.toFloat() / max.toFloat() + loudnessEnhancer?.release() + loudnessEnhancer = null + } + } + } + + /** + * Handles a hardware volume key press. + * Only active on phones/emulators when [isFullScreen] is true. + * + * @return true if the key was consumed (suppresses the system volume UI). + */ + fun handleVolumeKey(keyCode: Int): Boolean { + /** + * Some TVs do not support volume boosting, and overriding + * the volume buttons can be inconvenient for TV users. + * Since boosting volume is mainly useful on phones and emulators, + * we limit this feature to those devices. + */ + if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false + if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false + verifyVolume() + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isVolumeLocked = currentRequestedVolume < 1.0f + // +- 5% + handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) + return true + } + + fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { + val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + val cur = currentRequestedVolume + val locked = isVolumeLocked + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + + // Show toast + if (fromButton) { + // For button related request we only show a toast when we exceeded the volume. + if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { + showToast(R.string.volume_exceeded_100) + hasShownVolumeToast = true + } + } else { + val raw = cur + delta + // For swipes, we show toast that we need to swipe again. + if (raw > 1.0 && locked && !hasShownVolumeToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownVolumeToast = true + } + } + + // Set the current volume step. + if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) + + var hasBoostError = false + // Apply loudness enhancer for volumes > 100%, removes it if less. + if (next > 1.0f) { + val boost = ((next - 1.0f) * 1000).toInt() + val existing = loudnessEnhancer + if (existing != null) { + existing.setTargetGain(boost) + } else { + val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId + if (sessionId != null && sessionId != AudioManager.ERROR) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(boost); enabled = true + } + } catch (t: Throwable) { logError(t); hasBoostError = true } + } + } + } else { + loudnessEnhancer?.release(); loudnessEnhancer = null + } + + currentRequestedVolume = next + + val leftHolder = playerView.playerProgressbarLeftHolder ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return + + if (next > 1.0f) { + // Change color to show that LoudnessEnhancer broke + // this is not a real fix, but solves the crash issue. + level2.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) + ) + } + // Max is set high to make it smooth. + level1.max = 100_000 + level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.max = 100_000 + level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 + level2.isVisible = next > 1.0f + // Calculate the clamped index for the volume icon based on the requested volume. + val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) + icon.setImageResource(volumeIcons[iconIdx]) + + if (!leftHolder.isVisible || leftHolder.alpha < 1f) { + leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true + } + progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } + progressBarLeftHideRunnable = Runnable { + leftHolder.animate().cancel() + leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() + } + // Show the progress bar for 1.5 seconds. + leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) + } + + /** Brightness helpers */ + + /** + * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Exception) { + // Because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it. + useTrueSystemBrightness = false + getBrightness() + } + } else { + try { + (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + } catch (e: Exception) { + logError(e) + null + } + } + } + + /** + * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + ) + } catch (_: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = (context as? Activity)?.window?.attributes ?: return + // Use 0.004f instead of 0: on some devices a value too close to 0 causes the + // system to override with its own brightness, making fine-tuning impossible. + lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) + (context as? Activity)?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } + } + + fun handleBrightnessAdjustment(verticalAddition: Float) { + val lastBrightness = currentRequestedBrightness + val raw = currentRequestedBrightness + verticalAddition + val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) + + if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownBrightnessToast = true + } + + currentRequestedBrightness = next + if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) + + currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f + brightnessOverlay?.alpha = currentExtraBrightness + playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) + + val rightHolder = playerView.playerProgressbarRightHolder ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return + + level1.max = 100_000 + level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) + + if (extraBrightnessEnabled) { + level2.max = 100_000 + level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.isVisible = next > 1.0f + } + + icon.setImageResource( + // Clamp the value in case of extra brightness. + brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] + ) + + if (!rightHolder.isVisible || rightHolder.alpha < 1f) { + rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true + } + progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } + progressBarRightHideRunnable = Runnable { + rightHolder.animate().cancel() + rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() + } + rightHolder.postDelayed(progressBarRightHideRunnable, 1500) + } + + /** Zoom helpers */ + + /** + * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has + * an implicit zoom applied. + * + * This is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. + */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) return current + + val exoView = playerView.exoPlayerView + val videoView = exoView?.videoSurfaceView + + if (exoView == null || videoView == null || + exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { + return Matrix() + } + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1f / initAspect) + return Matrix().apply { postScale(aspect, aspect) } + } + + /** + * Applies [newMatrix] (scale + translation only) to the video surface view. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. + */ + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + val exoView = playerView.exoPlayerView ?: return + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + // Sanity check + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return + + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.height. + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) + val scaledAspect = scale * aspect + + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. + val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + // Correct the translation to clamp within the viewing area. + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + // Set the transform to the correct x and y. + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + // If we are not in an animation, set up the values for the animation. + if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it. + videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1f / aspect, 1f / aspect) + desiredMatrix = desired + } else if (scale < 1f) { + // We have zoomed too far, zoom to 100%. + videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + // Keep the same scaling after zoom. + videoOutline?.isVisible = false + desiredMatrix = null + } + } + + // Finally set the actual scale + translation. + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() + } + + /** + * Clears all zoom state and resets the video surface view to 1:1 scale. + * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. + */ + fun clearZoomState() { + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + scaleGestureDetector = null + lastPan = null + playerView.exoPlayerView?.videoSurfaceView?.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + /** + * Resets zoom to fit mode if any zoom is currently active. + * Calls [PlayerView.resize] to update the ExoPlayer resize mode. + */ + fun resetZoomToDefault() { + if (zoomMatrix != null) { + clearZoomState() + playerView.resize(PlayerResize.Fit, false) + } + } + + private fun createScaleGestureDetector(ctx: Context) { + scaleGestureDetector = ScaleGestureDetector( + ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. + val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + // This is how much we should scale it with to prevent infinite scaling. + val actualScaleFactor = newScale / scale + // Scale around the focus point, this is more natural than just zoom. + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) + applyZoomMatrix(matrix, false) + return true + } + } + ) + } + + /** + * Processes a two-finger zoom/pan gesture event. + * Handles scale detection, panning, and the snap-back animation after finger lift. + * + * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). + * @param ctx Context used to create the [ScaleGestureDetector] on first call. + * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). + * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). + * @return Always true (event consumed). + */ + fun handleZoomPanGesture( + event: MotionEvent, + ctx: Context, + onFirstPointerDown: () -> Unit, + onGestureEnd: () -> Unit + ): Boolean { + if (scaleGestureDetector == null) createScaleGestureDetector(ctx) + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + onFirstPointerDown() + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + } + + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + lastPan = null + videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() + } + + onGestureEnd() + } + } + return true + } + + /** + * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, + * accounting for zoom scale and translation. + */ + fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + + // Compute accurate transformed bounding box of the video view after scale+translation. + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw <= 0f || vh <= 0f) return + + // Pivot defaults to center if not set. + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation. + val tx = video.x + val ty = video.y + + // Transform function for a local point (lx,ly). + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) + val p2 = transform(0f, vh); val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else if (lp.width != newW || lp.height != newH) { + lp.width = newW; lp.height = newH + overlay.layoutParams = lp + } + + overlay.scaleX = 1f; overlay.scaleY = 1f + overlay.x = minX; overlay.y = minY + } + + /** + * Attaches a persistent layout-change listener to the ExoPlayer view so + * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, + * aspect-ratio change, zoom, PiP transition, etc.). + */ + fun requestUpdateBrightnessOverlayOnNextLayout() { + val exoView = playerView.exoPlayerView ?: return + overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + safe { updateBrightnessOverlayBounds() } + } + overlayLayoutListener = listener + exoView.addOnLayoutChangeListener(listener) + } + + /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ + fun releaseOverlayLayoutListener() { + overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } + overlayLayoutListener = null + } + + /** Rewind / fast-forward animations */ + + /** Resets the rewind button label to the standard "–Xs" format. */ + fun resetRewindText() { + playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** Resets the fast-forward button label to the standard "+Xs" format. */ + fun resetFastForwardText() { + playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** + * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). + * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. + * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share + * the same fade logic. + */ + fun animateCenterControls(fadeTo: Float) { + val from = if (fadeTo > 0.5f) 0f else 1f + fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } + // Each view needs its own Animation instance; sharing one causes fillAfter to + // not hold reliably across all views once any of them restarts the animation. + playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerPausePlay?.startAnimation(makeAnim()) + } + + /** Plays the rewind animation and seeks back by [fastForwardTime]. */ + fun rewind() { + try { + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + // Only expose the parent chain when controls are currently hidden. + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + rewHolder.alpha = 1f + + rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) + 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 onAnimationEnd(animation: Animation?) { + rewText?.post { + resetRewindText() + // Restore parent chain only if we changed it and controls are still hidden. + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + rewHolder.alpha = 0f + } + } + } + }) + rewText?.startAnimation(goLeft) + rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(-fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ + fun fastForward() { + try { + val ffwdHolder = playerView.playerFfwdHolder ?: return + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + ffwdHolder.alpha = 1f + + ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) + 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 onAnimationEnd(animation: Animation?) { + ffwdText?.post { + resetFastForwardText() + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + ffwdHolder.alpha = 0f + } + } + } + }) + ffwdText?.startAnimation(goRight) + ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Double-tap detection */ + + /** + * Call when a valid tap is detected (short hold, minimal movement, valid touch area). + * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. + * + * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. + * + * @param x X coordinate of the tap in the view's coordinate space. + * @param viewWidth Width of the view (used to compute left/center/right zones). + * @param isLocked Whether player controls are locked (suppresses double-tap seek). + * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. + * @return true if a double-tap action was performed. + */ + fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { + val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled + if (!anyDoubleTap) { + onSingleTap() + return false + } + + val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime + return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { + /** Double-tap */ + tapCount++ + doubleTapToken++ // cancel any pending single-tap runnable + if (doubleTapPauseEnabled) { + when { + x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) rewind() + } + x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) fastForward() + } + else -> { + playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + } else if (doubleTapEnabled) { + if (x < viewWidth / 2f) rewind() else fastForward() + } + true + } else { + /** Single tap (first tap, or too slow for double-tap) */ + tapCount = 0 + val token = ++doubleTapToken + playerView.playerHolder?.postDelayed({ + if (token == doubleTapToken) { + onSingleTap() + } + }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) + false + } + } + + /** Seek time helpers */ + + private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() + val duration = playerView.player.getDuration() ?: return null + return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) + } + + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added = letters - inp.toString().length + return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + // int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") + holder.setOnTouchListener(::handleGesture) + } + + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val holder = playerView.playerHolder ?: return true + val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom + val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right + return validHeight && validWidth + } + + return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + } + + private fun handleGesture(view: View, event: MotionEvent): Boolean { + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + /** Two-finger zoom/pan (fullscreen, unlocked) */ + if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked + && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches + return handleZoomPanGesture( + event = event, + ctx = view.context, + onFirstPointerDown = { + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + }, + onGestureEnd = { + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + } + ) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isCurrentTouchValid = isValidTouch(event.rawX, event.rawY) + if (isCurrentTouchValid) { + playerView.callbacks?.onTouchDown() + hasTriggeredSpeedUp = false + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + holdHandler.postDelayed(holdRunnable, 500) + } + isVolumeLocked = currentRequestedVolume < 1.0f + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = playerView.player.getPosition() + getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } + verifyVolume() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (hasTriggeredSpeedUp) return true + if (!isCurrentTouchValid) return true + + if (currentTouchAction == null && startTouch != null) { + val diffFromStart = startTouch - currentTouch + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + currentTouchAction = if ((startTouch.x) >= view.width / 2f) + TouchAction.Volume else TouchAction.Brightness + } + } + if (swipeHorizontalEnabled && !isLocked) { + if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + currentTouchAction = TouchAction.Time + } + } + } + + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() + 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 + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) + null -> Unit + } + if (currentTouchAction != TouchAction.Time) { + playerView.callbacks?.onSeekPreviewText(null) + } + } + currentTouchLast = currentTouch + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + holdHandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + showOrHideSpeedUp(false) + playerView.callbacks?.onHoldSpeedUp(false) + hasTriggeredSpeedUp = false + } + + if (isCurrentTouchValid) { + // Horizontal seek on release + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + playerView.player.seekTo(seekTo, PlayerEventSource.UI) + } + } + } + } + // Tap detection: only fire if the finger was held briefly (not a long-press). + val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + if (currentTouchAction == null && currentLastTouchAction == null + && !hasTriggeredSpeedUp + && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { + onTapDetected( + x = currentTouch.x, + viewWidth = view.width, + isLocked = isLocked, + onSingleTap = { playerView.callbacks?.onSingleTap() } + ) + } + } + + playerView.callbacks?.onSeekPreviewText(null) + val hadSwipe = currentTouchAction != null || currentLastTouchAction != null + playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + + // Reset touch + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } +} 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 7e9c39b01..0db06499e 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 @@ -1,121 +1,205 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity +import android.app.AppOpsManager import android.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.os.Build import android.util.Rational import androidx.annotation.RequiresApi import androidx.annotation.StringRes +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe 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 - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun getRemoteAction( - activity: Activity, - id: Int, - @StringRes title: Int, - event: CSPlayerEvent - ): RemoteAction { - val text = activity.getString(title) - return RemoteAction( - Icon.createWithResource(activity, id), - text, - text, - getPen(activity, event.value) - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) { - val actions: ArrayList = ArrayList() - actions.add( - getRemoteAction( - activity, - R.drawable.baseline_headphones_24, - R.string.audio_singluar, - CSPlayerEvent.PlayAsAudio - ) - ) - /*actions.add( - getRemoteAction( - activity, - R.drawable.go_back_30, - R.string.go_back_30, - CSPlayerEvent.SeekBack - ) - )*/ - - if (isPlaying) { - actions.add( - getRemoteAction( - activity, - R.drawable.netflix_pause, - R.string.pause, - CSPlayerEvent.Pause - ) - ) - } else { - actions.add( - getRemoteAction( - activity, - R.drawable.ic_baseline_play_arrow_24, - R.string.pause, - CSPlayerEvent.Play - ) - ) - } - - actions.add( - getRemoteAction( - activity, - R.drawable.go_forward_30, - R.string.go_forward_30, - 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) - } - - safe { - activity.setPictureInPictureParams( - PictureInPictureParams.Builder() - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setSeamlessResizeEnabled(true) - setAutoEnterEnabled(isPlaying) - } - } - .setAspectRatio(fixedRational) - .setActions(actions) - .build() - ) - } +object PlayerPipHelper { + /** Is pip (Player in Player) supported, and enabled? */ + fun Context.isPIPPossible() : Boolean { + return try { + this.hasPIPEnabled() && this.hasPIPFeature() + } catch (t : Throwable) { + // While both hasPIPEnabled and hasPIPFeature should never throw, this catches it just in case + logError(t) + false } } + + /** Is pip enabled in app settings? */ + private fun Context.hasPIPEnabled(): Boolean { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + settingsManager?.getBoolean( + getString(R.string.pip_enabled_key), + true + ) ?: true + } catch (e: Exception) { + logError(e) + false + } + } + + + /** + * Is pip supported by the OS? + * + * Source: + * 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 + * */ + private fun Context.hasPIPFeature(): Boolean = + // OS Support + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + // Might have the feature, but OS blocked due to power drain + this.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + // Might have been disabled by the user + this.hasPIPPermission() + + /** Is pip enabled in the OS settings? */ + private fun Context.hasPIPPermission(): Boolean { + val appOps = + getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) == AppOpsManager.MODE_ALLOWED + } else true + } + + @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 + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun getRemoteAction( + activity: Activity, + id: Int, + @StringRes title: Int, + event: CSPlayerEvent + ): RemoteAction { + val text = activity.getString(title) + return RemoteAction( + Icon.createWithResource(activity, id), + text, + text, + getPen(activity, event.value) + ) + } + + fun updatePIPModeActions( + activity: Activity?, + status: CSPlayerLoading, + pipEnabled: Boolean, + aspectRatio: Rational? + ) { + // Is it even desired to enter pip mode right now if we ignore all settings? + // This does not check for isPIPPossible as that is deferred to later + val isPipDesired = when (status) { + CSPlayerLoading.IsBuffering, CSPlayerLoading.IsPlaying -> pipEnabled + else -> false + } + + // On lower api ver setPictureInPictureParams is not supported, + // so we enter pip manually in onUserLeaveHint + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + CommonActivity.isPipDesired = isPipDesired + return + } + + if(activity == null) return + + val actions: ArrayList = ArrayList() + actions.add( + getRemoteAction( + activity, + R.drawable.baseline_headphones_24, + R.string.audio_singular, + CSPlayerEvent.PlayAsAudio + ) + ) + /*actions.add( + getRemoteAction( + activity, + R.drawable.go_back_30, + R.string.go_back_30, + CSPlayerEvent.SeekBack + ) + )*/ + + if (status == CSPlayerLoading.IsPlaying) { + actions.add( + getRemoteAction( + activity, + R.drawable.netflix_pause, + R.string.pause, + CSPlayerEvent.Pause + ) + ) + } else { + actions.add( + getRemoteAction( + activity, + R.drawable.ic_baseline_play_arrow_24, + R.string.pause, + CSPlayerEvent.Play + ) + ) + } + + actions.add( + getRemoteAction( + activity, + R.drawable.go_forward_30, + R.string.go_forward_30, + CSPlayerEvent.SeekForward + ) + ) + + // Necessary 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) + } + + safe { + activity.setPictureInPictureParams( + PictureInPictureParams.Builder() + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setSeamlessResizeEnabled(true) + setAutoEnterEnabled(isPipDesired && activity.isPIPPossible()) + } else { + // We enter pip manually in onUserLeaveHint as the smooth transition + // is not supported yet + CommonActivity.isPipDesired = isPipDesired + } + } + .setAspectRatio(fixedRational) + .setActions(actions) + .build() + ) + } + } + } 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 f1eb0468b..ee6170aa5 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 @@ -11,6 +11,7 @@ import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.UIHelper.toPx enum class SubtitleStatus { @@ -30,7 +31,7 @@ enum class SubtitleOrigin { * @param nameSuffix An extra suffix added to the subtitle to make sure it is unique * @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". + * @param languageCode usually, tags such as "en", "es-mx", or "zh-hant-TW". But it could be something like "English 4" * */ data class SubtitleData( val originalName: String, @@ -41,17 +42,23 @@ data class SubtitleData( val headers: Map, val languageCode: String?, ) { - companion object { - fun constructName(originalName: String, nameSuffix: String) = "$originalName $nameSuffix" - } - /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url else "$url|$name" } - val name = constructName(originalName, nameSuffix) + /** Returns true if langCode is the same as the IETF tag */ + fun matchesLanguageCode(langCode: String): Boolean { + return getIETF_tag() == langCode + } + + /** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */ + fun getIETF_tag(): String? { + return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true) + } + + val name = "$originalName $nameSuffix" /** * Gets the URL, but tries to fix it if it is malformed. @@ -104,7 +111,7 @@ class PlayerSubtitleHelper { origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), headers = subtitleFile.headers ?: emptyMap(), - languageCode = subtitleFile.lang + languageCode = subtitleFile.langTag ?: subtitleFile.lang ) } } @@ -122,7 +129,7 @@ class PlayerSubtitleHelper { fun setSubStyle(style: SaveCaptionStyle) { Log.i(TAG, "SET STYLE = $style") subtitleView?.translationY = -style.elevation.toPx.toFloat() - setSubtitleViewStyle(subtitleView, style) + setSubtitleViewStyle(subtitleView, style, true) } fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt new file mode 100644 index 000000000..0e6f1a367 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,842 @@ +package com.lagradost.cloudstream3.ui.player + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.format.DateUtils +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar +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.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +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.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.UIHelper.hideKeyboard +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.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException + +/** + * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event + * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] + * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. + */ +@OptIn(UnstableApi::class) +class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + companion object { + private const val TAG = "PlayerView" + } + + /** All gesture, volume, brightness and key-event logic lives here. */ + val gestureHelper = PlayerGestureHelper(this) + + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ + var isFullScreen: Boolean + get() = gestureHelper.isFullScreen + set(value) { gestureHelper.isFullScreen = value } + + var isLocked: Boolean + get() = gestureHelper.isLocked + set(value) { gestureHelper.isLocked = value } + + var videoOutline: View? + get() = gestureHelper.videoOutline + set(value) { gestureHelper.videoOutline = value } + + /** Delegate methods */ + fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) + fun verifyVolume() = gestureHelper.verifyVolume() + fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() + fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() + fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() + fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() + + /** Callbacks */ + + /** Host-fragment-level callbacks invoked by [mainCallback]. */ + interface Callbacks { + fun nextEpisode() {} + fun prevEpisode() {} + fun playerPositionChanged(position: Long, duration: Long) {} + fun playerStatusChanged() {} + fun playerDimensionsLoaded(width: Int, height: Int) {} + fun subtitlesChanged() {} + fun embeddedSubtitlesFetched(subtitles: List) {} + fun onTracksInfoChanged() {} + fun onTimestamp(timestamp: VideoSkipStamp?) {} + fun onTimestampSkipped(timestamp: VideoSkipStamp) {} + fun exitedPipMode() {} + fun hasNextMirror(): Boolean = false + fun nextMirror() {} + fun onDownload(event: DownloadEvent) {} + fun playerError(exception: Throwable) {} + /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ + fun playerUpdated(player: Any?) {} + /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ + fun onSingleTap() {} + /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ + fun onHoldSpeedUp(show: Boolean) {} + /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ + fun onBrightnessExtra(alpha: Float) {} + + /** Touch event callbacks */ + + /** Returns whether the player UI (controls overlay) is currently visible. */ + fun isUIShowing(): Boolean = false + /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ + fun onTouchDown() {} + /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ + fun onSeekPreviewText(text: String?) {} + /** Called when a swipe gesture begins; hide the player UI if desired. */ + fun onHidePlayerUI() {} + /** + * Called at the end of each touch sequence. + * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. + * @param wasUiShowing true if the UI was visible when the swipe began. + */ + fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} + /** + * Called when the auto-hide timer fires: UI is showing, no touch is active. + * Implement to hide the player controls. + */ + fun onAutoHideUI() {} + } + + var callbacks: Callbacks? = null + + /** Player state */ + + var player: IPlayer = CS3IPlayer() + var resizeMode: Int = 0 + var hasPipModeSupport: Boolean = true + var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering + var mMediaSession: MediaSession? = null + private var pipReceiver: BroadcastReceiver? = null + + /** Auto-hide */ + private var autoHideToken = 0 + private val autoHideHandler = Handler(Looper.getMainLooper()) + + /** View references (populated by bindViews) */ + + var subView: SubtitleView? = null + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ + var exoPlayerView: androidx.media3.ui.PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null + internal var playerRew: View? = null + internal var playerFfwd: View? = null + internal var exoRewText: TextView? = null + internal var exoFfwdText: TextView? = null + internal var playerCenterMenu: View? = null + internal var playerRewHolder: View? = null + internal var playerFfwdHolder: View? = null + internal var playerVideoHolder: View? = null + var playerProgressbarLeftHolder: RelativeLayout? = null + var playerProgressbarLeftIcon: ImageView? = null + var playerProgressbarLeftLevel1: ProgressBar? = null + var playerProgressbarLeftLevel2: ProgressBar? = null + var playerProgressbarRightHolder: RelativeLayout? = null + var playerProgressbarRightIcon: ImageView? = null + var playerProgressbarRightLevel1: ProgressBar? = null + var playerProgressbarRightLevel2: ProgressBar? = null + /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ + internal var playerSpeedupButton: View? = null + var playerHolder: FrameLayout? = null + private var exoDuration: TextView? = null + private var timeLeft: TextView? = null + private var exoPosition: TextView? = null + private var timeLive: View? = null + private var exoProgress: LivePreviewTimeBar? = null + + /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ + var seekTime: Long = 10_000L + + /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ + var isVerticalOrientation: Boolean = false + + /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ + var autoPlayerRotateEnabled: Boolean = false + + var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + + // Kept so SubtitlesFragment can unsubscribe the exact same reference. + private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged + + /** View discovery */ + + /** + * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply + * remain null, all usage is null-safe. + */ + fun bindViews(root: View) { + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + exoPlayerView = root.findViewById(R.id.player_view) + exoPosition = root.findViewById(R.id.exo_position) + exoRewText = root.findViewById(R.id.exo_rew_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerFfwd = root.findViewById(R.id.player_ffwd) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerHolder = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) + playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) + } + + /** + * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, + * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. + */ + fun initialize() { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + if (player is CS3IPlayer) { + // Preview bar + val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) + exoProgress = progressBar as? LivePreviewTimeBar + val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = + exoPlayerView?.findViewById(R.id.previewFrameLayout) + + /** Hide the previewFrameLayout on TV to make the skip op button not float, + * as previewFrameLayout is normally invisible */ + if(isLayout(TV)) { + previewFrameLayout?.isVisible = false + } + + if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) { + var resume = false + progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { + override fun onScrubStart(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + val hasPreview = cs3.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = cs3.getIsPlaying() + if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + // No clashing UI + if (hasPreview) subView?.isVisible = false + } + + override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} + + override fun onScrubStop(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + // Delay to prevent the small flicker of subtitle before seeking. + subView?.postDelayed({ + // If we are not scrubbing then show subtitles again. + if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { + subView?.isVisible = true + } + }, 200) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader + val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) + (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) + (player as? CS3IPlayer)?.let { + (it.imageGenerator as? PreviewGenerator)?.params = + ImageParams.new16by9(screenWidth) + } + + /** + * 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. + */ + exoPlayerView?.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 + ) + ) + } + }) + + // Read seek time and rotation settings. + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + autoPlayerRotateEnabled = sm.getBoolean( + context.getString(R.string.auto_rotate_video_key), true + ) + } catch (_: Exception) { + } + + val seekSecs = (seekTime / 1000).toInt() + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) + + playerPausePlay?.setOnClickListener { + scheduleAutoHide() + if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) + } else { + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + playerRew?.setOnClickListener { + scheduleAutoHide() + gestureHelper.rewind() + } + playerFfwd?.setOnClickListener { + scheduleAutoHide() + gestureHelper.fastForward() + } + + SubtitlesFragment.applyStyleEvent += subStyleListener + + try { + val ctx = context + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val cs3 = player as? CS3IPlayer ?: return + cs3.cacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L + cs3.simpleCacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L + cs3.videoBufferMs = + settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L + } catch (e: Exception) { + logError(e) + } + + // Duration toggle click listeners + exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } + timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } + // Keep remaining-time text in sync with playback position + exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } + + // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) + gestureHelper.initialize() + setupKeyEventListener() + + // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining + setRemainingTimeCounter(durationMode || isLayout(TV)) + } + } + + /** Lifecycle delegation */ + + var fullscreenNotch: Boolean = true // TODO SETTING + + fun enterFullscreen(updateOrientation: () -> Unit = {}) { + val activity = context as? Activity + if (isFullScreen) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params + } + } + updateOrientation() + } + + fun exitFullscreen() { + val activity = context as? Activity + gestureHelper.resetZoomToDefault() + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // Simply resets brightness and notch settings that might have been overridden. + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() + } + + fun onStop() { + player.onStop() + } + + fun onResume(ctx: Context) { + player.onResume(ctx) + } + + /** Releases all player resources. */ + fun release() { + player.release() + player.releaseCallbacks() + player = CS3IPlayer() + + // keyEventListener is deregistered in onPause so that the incoming player's + // onResume can register its own listener without racing against release(). + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + CSPlayerLoading.IsPaused, + false, + null + ) + + mMediaSession?.release() + mMediaSession = null + exoPlayerView?.player = null + + SubtitlesFragment.applyStyleEvent -= subStyleListener + + gestureHelper.release() + autoHideHandler.removeCallbacksAndMessages(null) + + keepScreenOn(false) + } + + fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + activity: Activity? + ) { + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_MEDIA_CONTROL != intent.action) return + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], + source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + activity?.registerReceiver(pipReceiver, filter) + } + val isPlaying = player.getIsPlaying() + val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(status, status) + } else { + // Restore the full-screen UI. + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + safe { activity?.unregisterReceiver(it) } + } + activity?.hideSystemUI() + hideKeyboard(this) + } + } catch (e: Exception) { + logError(e) + } + } + + /** Player UI helpers */ + + private fun keepScreenOn(on: Boolean) { + val window = (context as? Activity)?.window ?: return + if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(isPlayingRightNow || isBuffering) + + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) + } else if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play + ) + val drawable = playerPausePlay?.drawable + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } + } + if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } + if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + // Somehow the phone is wacked + if (!startedAnimation) { + playerPausePlay?.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 + ) + } + } + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + private fun playerUpdated(player: Any?) { + if (player is ExoPlayer) { + mMediaSession?.release() + mMediaSession = MediaSession.Builder(context, player) + // Ensure unique ID for concurrent players. + .setId(System.currentTimeMillis().toString()) + .build() + + // Necessary for multiple combined videos. + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. + player.seekTime(-1) + } + + /** Error handling */ + + @MainThread + fun playerError(exception: Throwable) { + fun showErrorToast(message: String) { + if (callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() + } + } + + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> + showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg") + + 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_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> + showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg") + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> + showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> + showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> + showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg") + + else -> + showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg") + } + } + + is SocketTimeoutException -> + showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") + + is ErrorLoadingException -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + + else -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + } + } + + /** Resize */ + + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() + resize(PlayerResize.entries[resize], showToast) + } + + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + exoPlayerView?.resizeMode = type + if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) + } + + /** Orientation */ + + /** + * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] + * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. + * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. + */ + fun dynamicOrientation(): Int { + if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + return if (autoPlayerRotateEnabled && isVerticalOrientation) + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + /** Event dispatch */ + + /** + * 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 WON'T stop it from changing in e.g. the player time + * or pause status. + */ + @MainThread + fun mainCallback(event: PlayerEvent) { + // We don't want to spam DownloadEvent. + if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") + when (event) { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { + // Skip 0x0 dimensions that the player emits when going to STATE_IDLE + // to avoid incorrectly resetting the auto-detected orientation. + if (event.width > 0 && event.height > 0) { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + } + callbacks?.playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) + is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error) + is RequestAudioFocusEvent -> requestAudioFocus() + is EpisodeSeekEvent -> when (event.offset) { + -1 -> callbacks?.prevEpisode() + 1 -> callbacks?.nextEpisode() + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + scheduleAutoHide() + callbacks?.playerStatusChanged() + } + is PositionEvent -> callbacks?.playerPositionChanged( + position = event.toMs, + duration = event.durationMs + ) + is VideoEndedEvent -> { + // Only play next episode if autoplay is on (default). + val ctx = context + 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 + } + } + + /** Duration display */ + + fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + exoDuration?.isInvisible = showRemaining + timeLeft?.isVisible = showRemaining + if (showRemaining) updateRemainingTime() + } + + fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (exoProgress?.isAtLiveEdge() == true) { + timeLeft?.alpha = 0f + exoDuration?.alpha = 0f + timeLive?.isVisible = true + } else { + timeLeft?.alpha = 1f + exoDuration?.alpha = 1f + timeLive?.isVisible = false + } + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + @SuppressLint("SetTextI18n") + timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + } + } + + /** Auto-hide */ + + /** + * Schedules a delayed auto-hide of the player UI after [delayMs] ms. + * Any previously pending hide is canceled first. + * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; + * the actual hide action is delegated to [Callbacks.onAutoHideUI]. + */ + fun scheduleAutoHide(delayMs: Long = 3000L) { + val token = ++autoHideToken + autoHideHandler.removeCallbacksAndMessages(null) + autoHideHandler.postDelayed({ + if (token != autoHideToken) return@postDelayed + if (gestureHelper.isCurrentTouchValid) return@postDelayed + if (callbacks?.isUIShowing() != true) return@postDelayed + callbacks?.onAutoHideUI() + }, delayMs) + } + + /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ + fun cancelAutoHide() { + autoHideToken++ + autoHideHandler.removeCallbacksAndMessages(null) + } +} 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 index 30e8d99ad..2893bcc47 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -9,7 +9,7 @@ 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.CloudStreamApp import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -65,7 +65,7 @@ interface IPreviewGenerator { companion object { fun new(): IPreviewGenerator { - val userDisabled = AcraApplication.context?.let { ctx -> + val userDisabled = CloudStreamApp.context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( ctx.getString(R.string.preview_seekbar_key), true ) == false 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 bfddd9e0c..0668a194b 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 @@ -9,8 +9,8 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import kotlin.math.max -import kotlin.math.min +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger data class Cache( val linkCache: MutableSet, @@ -22,10 +22,9 @@ data class Cache( ) class RepoLinkGenerator( - private val episodes: List, - private var currentIndex: Int = 0, + episodes: List, val page: LoadResponse? = null, -) : IGenerator { +) : VideoGenerator(episodes) { companion object { const val TAG = "RepoLink" val cache: HashMap, Cache> = @@ -34,44 +33,7 @@ class RepoLinkGenerator( override val hasCache = true override val canSkipLoading = true - - override fun hasNext(): Boolean { - return currentIndex < episodes.size - 1 - } - - override fun hasPrev(): Boolean { - return currentIndex > 0 - } - - override fun next() { - Log.i(TAG, "next") - if (hasNext()) - currentIndex++ - } - - override fun prev() { - Log.i(TAG, "prev") - if (hasPrev()) - currentIndex-- - } - - override fun goto(index: Int) { - Log.i(TAG, "goto $index") - // clamps value - currentIndex = min(episodes.size - 1, max(0, index)) - } - - override fun getCurrentId(): Int { - return episodes[currentIndex].id - } - - override fun getCurrent(offset: Int): Any? { - return episodes.getOrNull(currentIndex + offset) - } - - override fun getAll(): List { - return episodes - } + override fun getId(index: Int): Int? = videos.getOrNull(index)?.id // this is a simple array that is used to instantly load links if they are already loaded //var linkCache = Array>(size = episodes.size, init = { setOf() }) @@ -80,14 +42,13 @@ class RepoLinkGenerator( @Throws override suspend fun generateLinks( clearCache: Boolean, - allowedTypes: Set, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean, ): Boolean { - val index = currentIndex - val current = episodes.getOrNull(index + offset) ?: return false + val current = videos.getOrNull(offset) ?: return false val currentCache = synchronized(cache) { cache[current.apiName to current.id] ?: Cache( @@ -100,10 +61,12 @@ class RepoLinkGenerator( } } - // these act as a general filter to prevent duplication of links or names - val currentLinksUrls = mutableSetOf() // makes all urls unique - val currentSubsUrls = mutableSetOf() // makes all subs urls unique - val currentSubsNames = mutableSetOf() // makes all subs names unique + // These act as a general filter to prevent duplication of links or names + // Avoid any possible ConcurrentModificationException + val currentLinksUrls = ConcurrentHashMap.newKeySet() + val currentSubsUrls = ConcurrentHashMap.newKeySet() + // Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen! + val lastCountedSuffix = ConcurrentHashMap() synchronized(currentCache) { val outdatedCache = @@ -114,20 +77,23 @@ class RepoLinkGenerator( currentCache.subtitleCache.clear() currentCache.saturated = false } else if (currentCache.linkCache.isNotEmpty()) { - Log.d(TAG, "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago") + Log.d( + TAG, + "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago" + ) } // call all callbacks currentCache.linkCache.forEach { link -> currentLinksUrls.add(link.url) - if (allowedTypes.contains(link.type)) { + if (sourceTypes.contains(link.type)) { callback(link to null) } } currentCache.subtitleCache.forEach { sub -> currentSubsUrls.add(sub.url) - currentSubsNames.add(sub.name) + lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() subtitleCallback(sub) } @@ -146,25 +112,18 @@ class RepoLinkGenerator( subtitleCallback = { file -> Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) { + if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { return@loadLinks } - currentSubsUrls.add(correctFile.url) // this part makes sure that all names are unique for UX - val fixedName = correctFile.name.html().toString().trim() + val nameDecoded = correctFile.originalName.html().toString() + .trim() // `%3Ch1%3Esub%20name…` → `

sub name…` → `sub name…` + val suffixCount = + lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet() - var name = fixedName - var count = 1 - while (currentSubsNames.contains(name)) { - count++ - name = - SubtitleData.constructName(originalName = fixedName, nameSuffix = "$count") - } - - currentSubsNames.add(name) val updatedFile = - correctFile.copy(originalName = fixedName, nameSuffix = "$count") + correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") synchronized(currentCache) { if (currentCache.subtitleCache.add(updatedFile)) { @@ -175,14 +134,13 @@ class RepoLinkGenerator( }, callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (link.url.isBlank() || currentLinksUrls.contains(link.url)) { + if (link.url.isBlank() || !currentLinksUrls.add(link.url)) { return@loadLinks } - currentLinksUrls.add(link.url) synchronized(currentCache) { if (currentCache.linkCache.add(link)) { - if (allowedTypes.contains(link.type)) { + if (sourceTypes.contains(link.type)) { callback(Pair(link, null)) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt index fcc5d8589..824b5d1a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt @@ -23,6 +23,7 @@ package com.lagradost.cloudstream3.ui.player import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.os.Build import android.text.Layout.Alignment import android.text.StaticLayout import android.text.TextPaint @@ -58,9 +59,16 @@ class RoundedBackgroundColorSpan( return } - // we cant use StaticLayout.Builder() due to API val width = p.measureText(text, start, end) - val textLayout = + val textLayout: StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + StaticLayout.Builder + .obtain(text, 0, text.length, TextPaint(p), width.toInt()) + .setAlignment(alignment) + .setLineSpacing(0.0f, 1.0f) + .setIncludePad(true) + .build() + } else { + @Suppress("DEPRECATION") StaticLayout( text, TextPaint(p), @@ -70,6 +78,7 @@ class RoundedBackgroundColorSpan( 0.0f, true ) + } val center = (left + right).toFloat() * 0.5f diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt index 9e3e778be..fa65c322e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt @@ -5,9 +5,10 @@ import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import androidx.core.view.isInvisible -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.SubtitleOffsetItemBinding -import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import kotlin.math.roundToInt data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: List) { @@ -16,25 +17,67 @@ data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: Li class SubtitleOffsetItemAdapter( private var currentTimeMs: Long, - override val items: MutableList, val clickCallback: (SubtitleCue) -> Unit ) : - AppContextUtils.DiffAdapter(items) { + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.startTimeMs == b.startTimeMs + })) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = SubtitleOffsetItemBinding.inflate(inflater, parent, false) - return SubtitleViewHolder(binding) + return ViewHolderState(binding) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SubtitleViewHolder -> holder.bind(items[position]) + override fun onBindContent(holder: ViewHolderState, item: SubtitleCue, position: Int) { + val binding = holder.view as? SubtitleOffsetItemBinding ?: return + + binding.root.setOnClickListener { + clickCallback.invoke(item) + } + + binding.subtitleText.text = item.text.joinToString("\n") + + val timeMs = currentTimeMs + val startTime = item.startTimeMs + val endTime = item.endTimeMs + + val newAlpha = if (timeMs >= startTime) 1f else 0.5f + ObjectAnimator.ofFloat( + binding.subtitleText, + "alpha", + binding.subtitleText.alpha, + newAlpha + ).apply { + interpolator = DecelerateInterpolator() + }.start() + + val showProgress = timeMs in startTime..= it.value.startTimeMs }?.index ?: 0 } @@ -45,7 +88,9 @@ class SubtitleOffsetItemAdapter( val earlyTime = minOf(previousTime, timeMs) val lateTime = maxOf(previousTime, timeMs) - val affectedItems = items.withIndex().filter { cue -> + + // TODO Add binary search and notifyItemRangeChanged + val affectedItems = immutableCurrentList.withIndex().filter { cue -> // Padding is required in the range because changes can be done within one single subtitle range, // and that subtitle needs to be updated cue.value.startTimeMs in (earlyTime - cue.value.durationMs)..(lateTime + cue.value.durationMs) @@ -56,57 +101,4 @@ class SubtitleOffsetItemAdapter( this.notifyItemChanged(item.index) } } - - private inner class SubtitleViewHolder( - val binding: SubtitleOffsetItemBinding, - ) : - RecyclerView.ViewHolder(binding.root) { - - fun bind( - data: SubtitleCue - ) { - binding.root.setOnClickListener { - clickCallback.invoke(data) - } - - binding.subtitleText.text = data.text.joinToString("\n") - - val timeMs = currentTimeMs - val startTime = data.startTimeMs - val endTime = data.endTimeMs - - val newAlpha = if (timeMs >= startTime) 1f else 0.5f - ObjectAnimator.ofFloat( - binding.subtitleText, - "alpha", - binding.subtitleText.alpha, - newAlpha - ).apply { - interpolator = DecelerateInterpolator() - }.start() - - val showProgress = timeMs in startTime..?): UpdatedDefaultExtractorsFactory { - tsSubtitleFormats = ImmutableList.copyOf(subtitleFormats) + tsSubtitleFormats = subtitleFormats?.let { ImmutableList.copyOf(it) } return this } @@ -335,6 +349,14 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } + @Synchronized + override fun experimentalSetCodecsToParseWithinGopSampleDependencies( + codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int + ): UpdatedDefaultExtractorsFactory { + this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies + return this + } + /** * Sets flags for [JpegExtractor] instances created by the factory. * @@ -350,6 +372,21 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } + /** + * Sets flags for [HeifExtractor] instances created by the factory. + * + * @see HeifExtractor.HeifExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setHeifExtractorFlags( + flags: @HeifExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.heifFlags = flags + return this + } + @Synchronized override fun createExtractors(): Array { return createExtractors(Uri.EMPTY, HashMap()) @@ -457,21 +494,26 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { extractors.add( FragmentedMp4Extractor( subtitleParserFactory, - fragmentedMp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + fragmentedMp4Flags or + FragmentedMp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) + extractors.add( Mp4Extractor( subtitleParserFactory, - mp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + mp4Flags or + Mp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) } @@ -513,12 +555,7 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor()) - FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0 - && (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0 - ) { - extractors.add(HeifExtractor()) - } - + FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) FileTypes.AVIF -> extractors.add(AvifExtractor()) FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} else -> {} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt index 06b4c12c3..5937b1973 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt @@ -1,3 +1,14 @@ +@file:Suppress( + "ALL", + "DEPRECATION", + "RedundantVisibilityModifier", + "RemoveRedundantQualifierName", + "UNCHECKED_CAST", + "UNUSED", + "UNUSED_PARAMETER", + "UNUSED_VARIABLE" +) + /* * Copyright (C) 2016 The Android Open Source Project * @@ -30,18 +41,20 @@ import androidx.media3.common.ColorInfo import androidx.media3.common.DrmInitData import androidx.media3.common.DrmInitData.SchemeData import androidx.media3.common.Format +import androidx.media3.common.Metadata import androidx.media3.common.MimeTypes import androidx.media3.common.ParserException -import androidx.media3.common.util.Assertions import androidx.media3.common.util.Log import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util +import androidx.media3.container.DolbyVisionConfig import androidx.media3.container.NalUnitUtil import androidx.media3.extractor.AacUtil import androidx.media3.extractor.AvcConfig import androidx.media3.extractor.ChunkIndex -import androidx.media3.extractor.DolbyVisionConfig +import androidx.media3.extractor.ChunkIndexProvider +import androidx.media3.extractor.DtsUtil import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput @@ -50,12 +63,18 @@ import androidx.media3.extractor.HevcConfig import androidx.media3.extractor.MpegAudioUtil import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.SeekMap.Unseekable +import androidx.media3.extractor.SeekMap.SeekPoints +import androidx.media3.extractor.SeekPoint +import androidx.media3.extractor.TrackAwareSeekMap import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput.CryptoData import androidx.media3.extractor.TrueHdSampleRechunker +import androidx.media3.extractor.metadata.ThumbnailMetadata import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput +import com.google.common.base.Preconditions.checkArgument +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.base.Preconditions.checkState import com.google.common.collect.ImmutableList import java.io.IOException import java.nio.ByteBuffer @@ -63,13 +82,14 @@ import java.nio.ByteOrder import java.util.Arrays import java.util.Collections import java.util.Locale +import java.util.Objects import java.util.UUID import kotlin.math.max import kotlin.math.min /** Extracts data from the Matroska and WebM container formats. */ @UnstableApi -class UpdatedMatroskaExtractor internal constructor( +class UpdatedMatroskaExtractor private constructor( private val reader: EbmlReader, flags: @Flags Int, subtitleParserFactory: SubtitleParser.Factory @@ -108,6 +128,8 @@ class UpdatedMatroskaExtractor internal constructor( private var timecodeScale = C.TIME_UNSET private var durationTimecode = C.TIME_UNSET private var durationUs = C.TIME_UNSET + private var isWebm: Boolean = false + private var pendingEndTracks: Boolean // The track corresponding to the current TrackEntry element, or null. private var currentTrack: Track? = null @@ -120,6 +142,13 @@ class UpdatedMatroskaExtractor internal constructor( private var seekEntryPosition: Long = 0 // Cue related elements. + private val perTrackCues: SparseArray> + private var inCuesElement = false + private var currentCueTimeUs: Long = C.TIME_UNSET + private var currentCueTrackNumber: Int = C.INDEX_UNSET + private var currentCueClusterPosition: Long = C.INDEX_UNSET.toLong() + private var currentCueRelativePosition: Long = C.INDEX_UNSET.toLong() + private var primarySeekTrackNumber: Int = C.INDEX_UNSET private var seekForCues = false private var seekForSeekContent = false private var visitedSeekHeads: HashSet = HashSet() @@ -128,9 +157,6 @@ class UpdatedMatroskaExtractor internal constructor( private var cuesContentPosition = C.INDEX_UNSET.toLong() private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() private var clusterTimecodeUs = C.TIME_UNSET - private var cueTimesUs: androidx.media3.common.util.LongArray? = null - private var cueClusterPositions: androidx.media3.common.util.LongArray? = null - private var seenClusterPositionForCurrentCuePoint = false // Reading state. private var haveOutputSample = false @@ -207,6 +233,7 @@ class UpdatedMatroskaExtractor internal constructor( init { reader.init(InnerEbmlProcessor()) this.subtitleParserFactory = subtitleParserFactory + this.perTrackCues = SparseArray() seekForCuesEnabled = (flags and FLAG_DISABLE_SEEK_FOR_CUES) == 0 parseSubtitlesDuringExtraction = (flags and FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 varintReader = VarintReader() @@ -222,6 +249,7 @@ class UpdatedMatroskaExtractor internal constructor( encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) + pendingEndTracks = true } @Throws(IOException::class) @@ -244,6 +272,17 @@ class UpdatedMatroskaExtractor internal constructor( reader.reset() varintReader.reset() resetWriteSampleData() + inCuesElement = false + currentCueTimeUs = C.TIME_UNSET + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the + // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent + // Cues elements will be ignored by the parsing logic. + if (!sentSeekMap) { + perTrackCues.clear() + } for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER - ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT + ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT ID_DOC_TYPE, ID_NAME, ID_CODEC_ID, ID_LANGUAGE -> EbmlProcessor.ELEMENT_TYPE_STRING ID_SEEK_ID, ID_BLOCK_ADD_ID_EXTRA_DATA, ID_CONTENT_COMPRESSION_SETTINGS, ID_CONTENT_ENCRYPTION_KEY_ID, ID_SIMPLE_BLOCK, ID_BLOCK, ID_CODEC_PRIVATE, ID_PROJECTION_PRIVATE, ID_BLOCK_ADDITIONAL -> EbmlProcessor.ELEMENT_TYPE_BINARY @@ -330,11 +369,27 @@ class UpdatedMatroskaExtractor internal constructor( } ID_CUES -> { - cueTimesUs = androidx.media3.common.util.LongArray() - cueClusterPositions = androidx.media3.common.util.LongArray() + if (!sentSeekMap) { + inCuesElement = true + } + } + + ID_CUE_POINT -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = C.TIME_UNSET + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + } } - ID_CUE_POINT -> seenClusterPositionForCurrentCuePoint = false ID_CLUSTER -> if (!sentSeekMap) { // We need to build cues before parsing the cluster. if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { @@ -347,7 +402,7 @@ class UpdatedMatroskaExtractor internal constructor( } else { // We don't know where the Cues element is located. It's most likely omitted. Allow // playback, but disable seeking. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -359,7 +414,10 @@ class UpdatedMatroskaExtractor internal constructor( ID_CONTENT_ENCODING -> {} ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true - ID_TRACK_ENTRY -> currentTrack = Track() + ID_TRACK_ENTRY -> { + currentTrack = Track() + currentTrack!!.isWebm = isWebm + } ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true else -> {} } @@ -398,7 +456,7 @@ class UpdatedMatroskaExtractor internal constructor( } else { // Otherwise, if we not found any cues nor any more seek heads then we mark // this as unseekable. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -427,13 +485,67 @@ class UpdatedMatroskaExtractor internal constructor( ID_CUES -> { if (!sentSeekMap) { - extractorOutput!!.seekMap(buildSeekMap(cueTimesUs, cueClusterPositions)) + var hasAnyCues = false + for (i in 0 until perTrackCues.size()) { + if (perTrackCues.valueAt(i).isNotEmpty()) { + hasAnyCues = true + break + } + } + + if (!hasAnyCues || durationUs == C.TIME_UNSET) { + // Cues are missing, empty, or duration is unknown. + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + } else { + for (i in 0 until perTrackCues.size()) { + perTrackCues.valueAt(i).sort() + } + + val seekMap = MatroskaSeekMap( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + extractorOutput!!.seekMap(seekMap) + } sentSeekMap = true - } else { - // We have already built the cues. Ignore. + inCuesElement = false + for (i in 0 until tracks.size()) { + val track: Track = tracks.valueAt(i) + track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) + if (!track.waitingForDtsAnalysis) { + track.assertOutputInitialized() + track.output!!.format(requireNotNull(track.format)) + } + } + maybeEndTracks() + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueTimeUs != C.TIME_UNSET + && currentCueTrackNumber != C.INDEX_UNSET + && currentCueClusterPosition != C.INDEX_UNSET.toLong() + ) { + var trackCues = perTrackCues[currentCueTrackNumber] + if (trackCues == null) { + trackCues = ArrayList() + perTrackCues.put(currentCueTrackNumber, trackCues) + } + + trackCues.add( + MatroskaSeekMap.CuePointData( + currentCueTimeUs, + /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, + /* relativePosition= */ currentCueRelativePosition + ) + ) + } } - this.cueTimesUs = null - this.cueClusterPositions = null } ID_BLOCK_GROUP -> { @@ -509,17 +621,15 @@ class UpdatedMatroskaExtractor internal constructor( } ID_TRACK_ENTRY -> { - val currentTrack = Assertions.checkStateNotNull(this.currentTrack) + val currentTrack = checkNotNull(this.currentTrack) if (currentTrack.codecId == null) { throw ParserException.createForMalformedContainer( "CodecId is missing in TrackEntry element", /* cause= */null ) } else { - if (isCodecSupported( - currentTrack.codecId!! - ) - ) { - currentTrack.initializeOutput(extractorOutput!!, currentTrack.number) + if (isCodecSupported(currentTrack.codecId!!)) { + currentTrack.initializeFormat(currentTrack.number); + currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); tracks.put(currentTrack.number, currentTrack) } } @@ -529,10 +639,63 @@ class UpdatedMatroskaExtractor internal constructor( ID_TRACKS -> { if (tracks.size() == 0) { throw ParserException.createForMalformedContainer( - "No valid tracks were found", /* cause= */null + "No valid tracks were found", /* cause= */ null ) } - extractorOutput!!.endTracks() + + // Determine the track to use for default seeking. + var defaultVideoTrackNumber: Int = C.INDEX_UNSET + var firstVideoTrackNumber: Int = C.INDEX_UNSET + var defaultAudioTrackNumber: Int = C.INDEX_UNSET + var firstAudioTrackNumber: Int = C.INDEX_UNSET + + // If we're not going to seek for cues, output the formats immediately. + val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); + + for (i in 0 until tracks.size()) { + val trackItem: Track = tracks.valueAt(i) + + val trackType: @C.TrackType Int = trackItem.type + when (trackType) { + C.TRACK_TYPE_VIDEO -> { + if (trackItem.flagDefault) { + defaultVideoTrackNumber = trackItem.number + } + if (firstVideoTrackNumber == C.INDEX_UNSET) { + firstVideoTrackNumber = trackItem.number + } + } + + C.TRACK_TYPE_AUDIO -> { + if (trackItem.flagDefault) { + defaultAudioTrackNumber = trackItem.number + } + if (firstAudioTrackNumber == C.INDEX_UNSET) { + firstAudioTrackNumber = trackItem.number + } + } + } + + if (mayBeSendFormatsEarly) { + trackItem.assertOutputInitialized() + if (!trackItem.waitingForDtsAnalysis) { + trackItem.output!!.format(checkNotNull(trackItem.format)) + } + } + } + + primarySeekTrackNumber = when { + defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber + firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber + defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber + firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber + tracks.size() > 0 -> tracks.valueAt(0).number + else -> C.INDEX_UNSET + } + + if (mayBeSendFormatsEarly) { + maybeEndTracks() + } } else -> {} @@ -575,7 +738,16 @@ class UpdatedMatroskaExtractor internal constructor( ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L - ID_TRACK_TYPE -> getCurrentTrack(id).type = value.toInt() + ID_TRACK_TYPE -> { + val matroskaTrackType = value.toInt() + getCurrentTrack(id).type = when (matroskaTrackType) { + 1 -> C.TRACK_TYPE_VIDEO // Matroska video + 2 -> C.TRACK_TYPE_AUDIO // Matroska audio + 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle + 33 -> C.TRACK_TYPE_METADATA // Matroska metadata + else -> C.TRACK_TYPE_UNKNOWN + } + } ID_DEFAULT_DURATION -> getCurrentTrack(id).defaultSampleDurationNs = value.toInt() ID_MAX_BLOCK_ADDITION_ID -> getCurrentTrack(id).maxBlockAdditionId = value.toInt() ID_BLOCK_ADD_ID_TYPE -> getCurrentTrack(id).blockAddIdType = value.toInt() @@ -621,17 +793,35 @@ class UpdatedMatroskaExtractor internal constructor( } ID_CUE_TIME -> { - assertInCues(id) - cueTimesUs!!.add(scaleTimecodeToUs(value)) + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = scaleTimecodeToUs(value) + } } - ID_CUE_CLUSTER_POSITION -> if (!seenClusterPositionForCurrentCuePoint) { - assertInCues(id) - // If there's more than one video/audio track, then there could be more than one - // CueTrackPositions within a single CuePoint. In such a case, ignore all but the first - // one (since the cluster position will be quite close for all the tracks). - cueClusterPositions!!.add(value) - seenClusterPositionForCurrentCuePoint = true + ID_CUE_TRACK -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = value.toInt() + } + } + + ID_CUE_CLUSTER_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueClusterPosition == C.INDEX_UNSET.toLong()) { + currentCueClusterPosition = value + } + } + } + + ID_CUE_RELATIVE_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { + currentCueRelativePosition = value + } + } } ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) @@ -943,7 +1133,7 @@ class UpdatedMatroskaExtractor internal constructor( (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) val isKeyframe = - track.type == TRACK_TYPE_AUDIO + track.type == C.TRACK_TYPE_AUDIO || (id == ID_SIMPLE_BLOCK && (scratch.data[2].toInt() and 0x80) == 0x80) blockFlags = if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0 blockState = BLOCK_STATE_DATA @@ -1035,9 +1225,7 @@ class UpdatedMatroskaExtractor internal constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( @@ -1046,11 +1234,9 @@ class UpdatedMatroskaExtractor internal constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInCues(id: Int) { - if (cueTimesUs == null || cueClusterPositions == null) { + if (!inCuesElement) { throw ParserException.createForMalformedContainer( "Element $id must be in a Cues", /* cause= */null ) @@ -1079,6 +1265,7 @@ class UpdatedMatroskaExtractor internal constructor( } else { if (CODEC_ID_SUBRIP == track.codecId || CODEC_ID_ASS == track.codecId + || CODEC_ID_SSA == track.codecId || CODEC_ID_VTT == track.codecId ) { if (blockSampleCount > 1) { @@ -1168,7 +1355,7 @@ class UpdatedMatroskaExtractor internal constructor( if (CODEC_ID_SUBRIP == track.codecId) { writeSubtitleSampleData(input, SUBRIP_PREFIX, size) return finishWriteSampleData() - } else if (CODEC_ID_ASS == track.codecId) { + } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { writeSubtitleSampleData(input, SSA_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_VTT == track.codecId) { @@ -1176,6 +1363,20 @@ class UpdatedMatroskaExtractor internal constructor( return finishWriteSampleData() } + if (track.waitingForDtsAnalysis) { + checkNotNull(track.format) + if (DtsUtil.isSampleDtsHd(input, size)) { + track.format = track.format!! + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .build() + } + + track.output!!.format(track.format!!) + track.waitingForDtsAnalysis = false + maybeEndTracks() + } + val output = track.output if (!sampleEncodingHandled) { if (track.hasContentEncryption) { @@ -1342,7 +1543,7 @@ class UpdatedMatroskaExtractor internal constructor( } } else { if (track.trueHdSampleRechunker != null) { - Assertions.checkState(sampleStrippedBytes.limit() == 0) + checkState(sampleStrippedBytes.limit() == 0) track.trueHdSampleRechunker!!.startSample(input) } while (sampleBytesRead < size) { @@ -1441,57 +1642,6 @@ class UpdatedMatroskaExtractor internal constructor( return bytesWritten } - /** - * Builds a [SeekMap] from the recently gathered Cues information. - * - * @return The built [SeekMap]. The returned [SeekMap] may be unseekable if cues - * information was missing or incomplete. - */ - private fun buildSeekMap( - cueTimesUs: androidx.media3.common.util.LongArray?, - cueClusterPositions: androidx.media3.common.util.LongArray? - ): SeekMap { - if (segmentContentPosition == C.INDEX_UNSET.toLong() || durationUs == C.TIME_UNSET || cueTimesUs == null || cueTimesUs.size() == 0 || cueClusterPositions == null || cueClusterPositions.size() != cueTimesUs.size()) { - // Cues information is missing or incomplete. - return Unseekable(durationUs) - } - val cuePointsSize = cueTimesUs.size() - var sizes = IntArray(cuePointsSize) - var offsets = LongArray(cuePointsSize) - var durationsUs = LongArray(cuePointsSize) - var timesUs = LongArray(cuePointsSize) - for (i in 0.. 0 && timesUs[lastValidIndex] > durationUs) { - lastValidIndex-- - } - - // Calculate sizes and durations for the last valid index - sizes[lastValidIndex] = - (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() - durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] - - // If the last valid index is not the last cue point, truncate the arrays - if (lastValidIndex < cuePointsSize - 1) { - Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration") - sizes = sizes.copyOf(lastValidIndex + 1) - offsets = offsets.copyOf(lastValidIndex + 1) - durationsUs = durationsUs.copyOf(lastValidIndex + 1) - timesUs = timesUs.copyOf(lastValidIndex + 1) - } - - return ChunkIndex(sizes, offsets, durationsUs, timesUs) - } - /** * Updates the position of the holder to Cues element's position if the extractor configuration * permits use of master seek entry. After building Cues sets the holder's position back to where @@ -1511,7 +1661,7 @@ class UpdatedMatroskaExtractor internal constructor( // (until cues or end of segment). However this also means that we only need to seek // back to the top once, instead seeking back in a stack like manner. if (seekForSeekContent) { - Assertions.checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") + checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") // The exact order does not really matter, but it is easiest to just do stack (FILO) val next = pendingSeekHeads.removeAt(pendingSeekHeads.size - 1) seekPosition.position = next @@ -1558,11 +1708,22 @@ class UpdatedMatroskaExtractor internal constructor( } private fun assertInitialized() { - Assertions.checkStateNotNull( + checkNotNull( extractorOutput ) } + private fun maybeEndTracks() { + if (!pendingEndTracks) return + + for (i in 0 until tracks.size()) { + if (tracks.valueAt(i).waitingForDtsAnalysis) return + } + + checkNotNull(extractorOutput).endTracks() + pendingEndTracks = false + } + /** Passes events through to the outer [UpdatedMatroskaExtractor]. */ private inner class InnerEbmlProcessor : EbmlProcessor { override fun getElementType(id: Int): @EbmlProcessor.ElementType Int { @@ -1607,10 +1768,11 @@ class UpdatedMatroskaExtractor internal constructor( /** Holds data corresponding to a single track. */ protected class Track { // Common elements. + var isWebm: Boolean = false var name: String? = null var codecId: String? = null var number: Int = 0 - var type: Int = 0 + var type: @C.TrackType Int = 0 var defaultSampleDurationNs: Int = 0 var maxBlockAdditionId: Int = 0 var blockAddIdType: Int = 0 @@ -1660,23 +1822,24 @@ class UpdatedMatroskaExtractor internal constructor( var sampleRate: Int = 8000 var codecDelayNs: Long = 0 var seekPreRollNs: Long = 0 - var trueHdSampleRechunker: TrueHdSampleRechunker? = - null + var trueHdSampleRechunker: TrueHdSampleRechunker? = null + var waitingForDtsAnalysis: Boolean = false // Text elements. var flagForced: Boolean = false + + // Common track elements. var flagDefault: Boolean = true var language: String = "eng" // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. var output: TrackOutput? = null + var format: Format? = null var nalUnitLengthFieldLength: Int = 0 - /** Initializes the track with an output. */ - @Throws( - ParserException::class - ) - fun initializeOutput(output: ExtractorOutput, trackId: Int) { + /** Builds the [Format] for the track. */ + @Throws(ParserException::class) + fun initializeFormat(trackId: Int) { var mimeType: String var maxInputSize = Format.NO_VALUE var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE @@ -1684,8 +1847,20 @@ class UpdatedMatroskaExtractor internal constructor( var codecs: String? = null when (codecId) { CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 - CODEC_ID_VP9 -> mimeType = MimeTypes.VIDEO_VP9 - CODEC_ID_AV1 -> mimeType = MimeTypes.VIDEO_AV1 + CODEC_ID_VP9 -> { + mimeType = MimeTypes.VIDEO_VP9 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } + CODEC_ID_AV1 -> { + mimeType = MimeTypes.VIDEO_AV1 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { mimeType = MimeTypes.VIDEO_MP4V @@ -1797,7 +1972,10 @@ class UpdatedMatroskaExtractor internal constructor( trueHdSampleRechunker = TrueHdSampleRechunker() } - CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> mimeType = MimeTypes.AUDIO_DTS + CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { + mimeType = MimeTypes.AUDIO_DTS // temporary + waitingForDtsAnalysis = true + } CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD CODEC_ID_FLAC -> { mimeType = MimeTypes.AUDIO_FLAC @@ -1896,7 +2074,7 @@ class UpdatedMatroskaExtractor internal constructor( } CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { mimeType = MimeTypes.TEXT_SSA initializationData = ImmutableList.of( SSA_DIALOGUE_FORMAT, getCodecPrivate( @@ -1942,18 +2120,15 @@ class UpdatedMatroskaExtractor internal constructor( selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 - val type: Int val formatBuilder = Format.Builder() // TODO: Consider reading the name elements of the tracks and, if present, incorporating them // into the trackId passed when creating the formats. if (MimeTypes.isAudio(mimeType)) { - type = C.TRACK_TYPE_AUDIO formatBuilder .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) } else if (MimeTypes.isVideo(mimeType)) { - type = C.TRACK_TYPE_VIDEO if (displayUnit == DISPLAY_UNIT_PIXELS) { displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight @@ -2014,7 +2189,6 @@ class UpdatedMatroskaExtractor internal constructor( || MimeTypes.APPLICATION_PGS == mimeType || MimeTypes.APPLICATION_DVBSUBS == mimeType ) { - type = C.TRACK_TYPE_TEXT } else { throw ParserException.createForMalformedContainer( "Unexpected MIME type.", /* cause= */null @@ -2025,9 +2199,10 @@ class UpdatedMatroskaExtractor internal constructor( formatBuilder.setLabel(name) } - val format = + format = formatBuilder .setId(trackId) + .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) .setSampleMimeType(mimeType) .setMaxInputSize(maxInputSize) .setLanguage(language) @@ -2036,9 +2211,6 @@ class UpdatedMatroskaExtractor internal constructor( .setCodecs(codecs) .setDrmInitData(drmInitData) .build() - - this.output = output.track(number, type) - this.output!!.format(format) } /** Forces any pending sample metadata to be flushed to the output. */ @@ -2113,6 +2285,90 @@ class UpdatedMatroskaExtractor internal constructor( return hdrStaticInfoData } + /** + * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as + * [ThumbnailMetadata]. + */ + fun maybeAddThumbnailMetadata( + perTrackCues: SparseArray>, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ) { + if (type != C.TRACK_TYPE_VIDEO) return + + val cuePoints = perTrackCues[number] + if (cuePoints.isNullOrEmpty()) return + + val thumbnailTimestampUs = findBestThumbnailPresentationTimeUs( + cuePoints, durationUs, segmentContentPosition, segmentContentSize + ) + + if (thumbnailTimestampUs != C.TIME_UNSET) { + val currentFormat = requireNotNull(format) + val existingMetadata = currentFormat.metadata + val thumbnailMetadata = ThumbnailMetadata(thumbnailTimestampUs) + val newMetadata = if (existingMetadata == null) { + Metadata(thumbnailMetadata) + } else { + existingMetadata.copyWithAppendedEntries(thumbnailMetadata) + } + format = currentFormat.buildUpon().setMetadata(newMetadata).build() + } + } + + /** + * Finds the best thumbnail timestamp from the provided cue points. + * + *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk + * size corresponds to a more complex and representative frame. It calculates an approximate + * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. + */ + private fun findBestThumbnailPresentationTimeUs( + cuePoints: MutableList, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ): Long { + if (cuePoints.isEmpty()) return C.TIME_UNSET + + var maxBitrate = 0.0 + var bestCueIndex = -1 + val scanLimit = min(cuePoints.size, MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL) + + for (i in 0 until scanLimit) { + val cue = cuePoints[i] + + if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) break + + val bytesBetweenCues: Long + val durationBetweenCuesUs: Long + + if (i < cuePoints.size - 1) { + val nextCue = cuePoints[i + 1] + bytesBetweenCues = (nextCue.clusterPosition + nextCue.relativePosition) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = nextCue.timeUs - cue.timeUs + } else { + // Last cue point + bytesBetweenCues = (segmentContentPosition + segmentContentSize) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = durationUs - cue.timeUs + } + + if (durationBetweenCuesUs > 0) { + // This is an approximation of the bitrate for thumbnail heuristic. + val bitrate = bytesBetweenCues.toDouble() / durationBetweenCuesUs + if (bitrate > maxBitrate) { + maxBitrate = bitrate + bestCueIndex = i + } + } + } + + return if (bestCueIndex == -1) C.TIME_UNSET else cuePoints[bestCueIndex].timeUs + } + /** * Checks that the track has an output. * @@ -2122,14 +2378,12 @@ class UpdatedMatroskaExtractor internal constructor( * fact at runtime. */ fun assertOutputInitialized() { - Assertions.checkNotNull( + checkNotNull( output ) } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun getCodecPrivate(codecId: String): ByteArray { if (codecPrivate == null) { throw ParserException.createForMalformedContainer( @@ -2368,6 +2622,7 @@ class UpdatedMatroskaExtractor internal constructor( private const val CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE" private const val CODEC_ID_SUBRIP = "S_TEXT/UTF8" private const val CODEC_ID_ASS = "S_TEXT/ASS" + private const val CODEC_ID_SSA = "S_TEXT/SSA" private const val CODEC_ID_VTT = "S_TEXT/WEBVTT" private const val CODEC_ID_VOBSUB = "S_VOBSUB" private const val CODEC_ID_PGS = "S_HDMV/PGS" @@ -2444,8 +2699,10 @@ class UpdatedMatroskaExtractor internal constructor( private const val ID_CUES = 0x1C53BB6B private const val ID_CUE_POINT = 0xBB private const val ID_CUE_TIME = 0xB3 + private const val ID_CUE_TRACK = 0xF7 private const val ID_CUE_TRACK_POSITIONS = 0xB7 private const val ID_CUE_CLUSTER_POSITION = 0xF1 + private const val ID_CUE_RELATIVE_POSITION = 0xF0 private const val ID_LANGUAGE = 0x22B59C private const val ID_PROJECTION = 0x7670 private const val ID_PROJECTION_TYPE = 0x7671 @@ -2500,6 +2757,12 @@ class UpdatedMatroskaExtractor internal constructor( private const val FOURCC_COMPRESSION_H263 = 0x33363248 private const val FOURCC_COMPRESSION_VC1 = 0x31435657 + /** The maximum number of chunks to scan when searching for a thumbnail. */ + private const val MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20 + + /** The maximum duration to scan for a thumbnail, in microseconds. */ + private const val MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L + /** * A template for the prefix that must be added to each subrip sample. * @@ -2721,8 +2984,8 @@ class UpdatedMatroskaExtractor internal constructor( * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use * the duration as the end timecode. * - * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS] or - * [.CODEC_ID_VTT]. + * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], + * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. * @param durationUs The duration of the sample, in microseconds. * @param subtitleData The subtitle sample in which to overwrite the end timecode (output * parameter). @@ -2741,7 +3004,7 @@ class UpdatedMatroskaExtractor internal constructor( endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET } - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { endTimecode = formatSubtitleTimecode( durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR @@ -2769,7 +3032,7 @@ class UpdatedMatroskaExtractor internal constructor( timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long ): ByteArray { var timeUs = timeUs - Assertions.checkArgument(timeUs != C.TIME_UNSET) + checkArgument(timeUs != C.TIME_UNSET) val timeCodeData: ByteArray val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) @@ -2787,7 +3050,7 @@ class UpdatedMatroskaExtractor internal constructor( private fun isCodecSupported(codecId: String): Boolean { return when (codecId) { - CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true + CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true else -> false } @@ -2811,4 +3074,169 @@ class UpdatedMatroskaExtractor internal constructor( } } } -} \ No newline at end of file + + class MatroskaSeekMap( + private val perTrackCues: SparseArray>, + private val durationUs: Long, + private val primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ) : TrackAwareSeekMap, ChunkIndexProvider { + + private val chunkIndex: ChunkIndex? = + buildChunkIndex( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + + override fun isSeekable(): Boolean { + // The media is seekable overall only if the primary seek track has cue points. + return isSeekable(primarySeekTrackNumber) + } + + override fun isSeekable(trackId: Int): Boolean { + val cuePoints = perTrackCues[trackId] + return !cuePoints.isNullOrEmpty() + } + + override fun getDurationUs(): Long = durationUs + + override fun getSeekPoints(timeUs: Long): SeekPoints = + chunkIndex?.getSeekPoints(timeUs) + ?: SeekPoints(SeekPoint.START) + + override fun getSeekPoints(timeUs: Long, trackId: Int): SeekPoints { + var cuePoints = perTrackCues[trackId] + + if ((cuePoints.isNullOrEmpty()) && trackId != primarySeekTrackNumber) { + cuePoints = perTrackCues[primarySeekTrackNumber] + } + + if (cuePoints.isNullOrEmpty()) { + return SeekPoints(SeekPoint.START) + } + + val bestIndex = Util.binarySearchFloor( + cuePoints, + CuePointData(timeUs, C.INDEX_UNSET.toLong(), C.INDEX_UNSET.toLong()), + /* inclusive= */ true, + /* stayInBounds= */ false + ) + + return if (bestIndex != -1) { + val bestCue = cuePoints[bestIndex] + val firstPoint = SeekPoint(bestCue.timeUs, bestCue.clusterPosition) + + if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size) { + val nextCue = cuePoints[bestIndex + 1] + val secondPoint = SeekPoint(nextCue.timeUs, nextCue.clusterPosition) + SeekPoints(firstPoint, secondPoint) + } else { + SeekPoints(firstPoint) + } + } else { + val firstCue = cuePoints[0] + SeekPoints(SeekPoint(firstCue.timeUs, firstCue.clusterPosition)) + } + } + + override fun getChunkIndex(): ChunkIndex? = chunkIndex + + private companion object { + + private fun buildChunkIndex( + perTrackCues: SparseArray>, + durationUs: Long, + primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ): ChunkIndex? { + + val primaryTrackCuePoints = + perTrackCues[primarySeekTrackNumber] ?: return null + + if (primaryTrackCuePoints.isEmpty()) { + return null + } + + val cuePointsSize = primaryTrackCuePoints.size + var sizes = IntArray(cuePointsSize) + var offsets = LongArray(cuePointsSize) + var durationsUs = LongArray(cuePointsSize) + var timesUs = LongArray(cuePointsSize) + + for (i in 0 until cuePointsSize) { + val cue = primaryTrackCuePoints[i] + timesUs[i] = cue.timeUs + offsets[i] = cue.clusterPosition + } + + for (i in 0 until cuePointsSize - 1) { + sizes[i] = (offsets[i + 1] - offsets[i]).toInt() + durationsUs[i] = timesUs[i + 1] - timesUs[i] + } + + // Start from the last cue point and move backward until a valid duration is found. + var lastValidIndex = cuePointsSize - 1 + while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { + lastValidIndex-- + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] + + // If trailing cue points were found, truncate the arrays to the last valid index. + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration.") + sizes = sizes.copyOf(lastValidIndex + 1) + offsets = offsets.copyOf(lastValidIndex + 1) + durationsUs = durationsUs.copyOf(lastValidIndex + 1) + timesUs = timesUs.copyOf(lastValidIndex + 1) + } + + return ChunkIndex(sizes, offsets, durationsUs, timesUs) + } + } + + class CuePointData( + /** The timestamp of the cue point, in microseconds. */ + val timeUs: Long, + + /** The absolute byte offset of the start of the cluster containing this cue point. */ + val clusterPosition: Long, + + /** + * The relative byte offset of the cue point's data block within its cluster. + * + *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. + */ + val relativePosition: Long + ) : Comparable { + + override fun compareTo(other: CuePointData): Int { + return timeUs.compareTo(other.timeUs) + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is CuePointData) { + return false + } + return this.timeUs == other.timeUs && + this.clusterPosition == other.clusterPosition && + this.relativePosition == other.relativePosition + } + + override fun hashCode(): Int { + return Objects.hash(timeUs, clusterPosition, relativePosition) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt new file mode 100644 index 000000000..52cd4361b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.mvvm.debugWarning +import java.util.WeakHashMap + +object LiveHelper { + private val liveManagers = WeakHashMap>() + + @OptIn(UnstableApi::class) + fun registerPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper registerPlayer called with null player!" } + return + } + + // Prevent duplicates + if (liveManagers.contains(player)) { + return + } + + val liveManager = LiveManager(player) + val listener = object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val window = Timeline.Window() + timeline.getWindow(player.currentMediaItemIndex, window) + if (window.isDynamic) { + liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) + } + super.onTimelineChanged(timeline, reason) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) + + // Seek back to the optimal live spot + if (timeAheadOfLive > 100) { + player.seekTo(newPosition.positionMs - timeAheadOfLive) + } + } + } + + synchronized(liveManagers) { + player.addListener(listener) + liveManagers[player] = liveManager to listener + } + } + + fun unregisterPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper unregisterPlayer called with null player!" } + return + } + + // Prevent duplicates + if (!liveManagers.contains(player)) { + return + } + + synchronized(liveManagers) { + liveManagers[player]?.let { (_, listener) -> + player.removeListener(listener) + } + liveManagers.remove(player) + } + } + + fun getLiveManager(player: Player?) = liveManagers[player]?.first +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 000000000..8d848d46a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import java.lang.ref.WeakReference + +// How much margin from the live point is still considered "live" +const val LIVE_MARGIN = 6_000L + +// How many ms should we be behind the real live point? +// Too low, and we cannot pre-buffer +// Too high, and we are no longer live +const val PREFERRED_LIVE_OFFSET = 5_000L + +// An extra offset from the optimal calculated timestamp +// This is to account for chunk updates not always being the same size +const val CHUNK_VARIANCE = 3000L + +// A livestream chunk from the player, the time we get it and the duration can be used to calculate +// the expected live timestamp. +class LivestreamChunk( + durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() +) { + // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. + // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. + val targetPosition = maxOf(0,minOf( + durationMs - PREFERRED_LIVE_OFFSET, + durationMs / 2 - CHUNK_VARIANCE + )) + + fun isPositionLive(position: Long): Boolean { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET + // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") + return withinLive + } + + fun getTimeAheadOfLive(position: Long): Long { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + // println("Ahead of live: ${position-livePosition}") + return position - livePosition + } +} + +// There are two types of livestreams we need to manage +// 1. A livestream with no history, a continually sliding window. +// This livestream has no currentLiveOffset, which means we need to calculate +// the real live point based on when we receive the latest update and the size of that update. +// 2. A livestream with history. +// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. +// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. +class LiveManager { + private var _currentPlayer: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayer?.get() + + constructor(player: Player?) { + _currentPlayer = WeakReference(player) + } + + private var lastLivestreamChunk: LivestreamChunk? = null + + fun submitLivestreamChunk(chunk: LivestreamChunk) { + lastLivestreamChunk = chunk + } + + /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ + fun getTimeAheadOfLive(position: Long): Long { + val player = currentPlayer ?: return 0 + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 + + // If the currentLiveOffset is wrong we fall back to manual calculations + val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + val relativeOffset = player.currentLiveOffset - player.currentPosition + position + PREFERRED_LIVE_OFFSET - relativeOffset + } else { + lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 + } + + // Ensure min of 0 + return maxOf(0, ahead) + } + + /** Check if the stream is currently at the expected live edge, with margins */ + fun isAtLiveEdge(): Boolean { + val player = currentPlayer ?: return false + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false + + // If the currentLiveOffset is wrong we fall back to manual calculations + return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET + } else { + lastLivestreamChunk?.isPositionLive(player.currentPosition) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 000000000..3001281fd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.ui.player.live + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.R +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import java.lang.ref.WeakReference + + +@OptIn(UnstableApi::class) +class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { + + private var _currentPlayerView: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(R.id.exo_controller) + + controller?.setProgressUpdateListener { position, bufferedPosition -> + currentPlayer?.let { player -> + if (isAtLiveEdge()) { + setPosition(player.duration) + } + } + } + } + + fun isAtLiveEdge(): Boolean { + return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true + } +} \ No newline at end of file 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 index ce457740d..11dd39105 100644 --- 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 @@ -2,9 +2,9 @@ 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 +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState data class SourcePriority( val data: T, @@ -12,41 +12,41 @@ data class SourcePriority( 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), +class PriorityAdapter() : + NoStateAdapter>() { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerPrioritizeItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PriorityViewHolder -> holder.bind(items[position]) + override fun onBindContent( + holder: ViewHolderState, + item: SourcePriority, + position: Int + ) { + val binding = holder.view as? PlayerPrioritizeItemBinding ?: return + binding.priorityText.text = item.name + + fun updatePriority() { + binding.priorityNumber.text = item.priority.toString() } - } - - 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.addButton.setOnClickListener { - // If someone clicks til the integer limit then they deserve to crash. - item.priority++ - updatePriority() - } + } - binding.subtractButton.setOnClickListener { - 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 index 821bccd6a..85c2a85df 100644 --- 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 @@ -10,45 +10,25 @@ import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.palette.graphics.Palette -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding -import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.drawableToBitmap +import com.lagradost.cloudstream3.utils.setText class ProfilesAdapter( - override val items: MutableList, - val usedProfile: Int, + 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) - ) - } + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.id == b.id + })) { - 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( + companion object { + private val art = arrayOf( R.drawable.profile_bg_teal, R.drawable.profile_bg_blue, R.drawable.profile_bg_dark_blue, @@ -57,67 +37,101 @@ class ProfilesAdapter( 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 + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerQualityProfileItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } - 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 - val drawableResId = art[index % art.size] - profileBg.loadImage(drawableResId) - - val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) - if (drawable != null) { - // Convert Drawable to Bitmap - val bitmap = drawableToBitmap(drawable) - if (bitmap != null) { - // Use Palette to extract colors from the bitmap - Palette.from(bitmap).generate { palette -> - val color = palette?.getDarkVibrantColor( - ContextCompat.getColor( - itemView.context, - R.color.dubColorBg - ) - ) - - if (color != null) { - 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() + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is PlayerQualityProfileItemBinding -> { + clearImage(binding.profileImageBackground) } } } + + override fun onBindContent( + holder: ViewHolderState, + item: QualityDataHelper.QualityProfile, + position: Int + ) { + val binding = holder.view as? PlayerQualityProfileItemBinding ?: return + + val priorityText: TextView = binding.profileText + val profileBg: ImageView = binding.profileImageBackground + val wifiText: TextView = binding.textIsWifi + val dataText: TextView = binding.textIsMobileData + val downloadText: TextView = binding.textIsDownloadData + val outline: View = binding.outline + val cardView: View = binding.cardView + val itemView = holder.itemView + + priorityText.setText(item.name) + dataText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Data) + wifiText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.WiFi) + downloadText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Download) + + fun setCurrentItem() { + val prevIndex = currentItem + // Prevent UI bug when re-selecting the item quickly + if (prevIndex == position) { + return + } + currentItem = position + clickCallback.invoke(prevIndex, position) + } + + outline.isVisible = currentItem == position + val drawableResId = art[position % art.size] + profileBg.loadImage(drawableResId) + + val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) + if (drawable != null) { + // Convert Drawable to Bitmap + val bitmap = drawableToBitmap(drawable) + if (bitmap != null) { + // Use Palette to extract colors from the bitmap + Palette.from(bitmap).generate { palette -> + val color = palette?.getDarkVibrantColor( + ContextCompat.getColor( + itemView.context, + R.color.dubColorBg + ) + ) + + if (color != null) { + wifiText.backgroundTintList = ColorStateList.valueOf(color) + dataText.backgroundTintList = ColorStateList.valueOf(color) + downloadText.backgroundTintList = ColorStateList.valueOf(color) + } + } + } + } + + val textStyle = + if (item.id == usedProfile) { + Typeface.BOLD + } else { + Typeface.NORMAL + } + + priorityText.setTypeface(null, textStyle) + + cardView.setOnClickListener { + setCurrentItem() + } + } + + private var currentItem: Int? = null + + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return currentItem?.let { index -> immutableCurrentList.getOrNull(index) } + } } \ 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 index 0922bdb5a..02470484e 100644 --- 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 @@ -1,22 +1,32 @@ 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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities +import kotlin.math.abs 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" + + // Old key only supporting one type per profile + @Deprecated("Changed to support multiple types per profile") private const val VIDEO_PROFILE_TYPE = "video_profile_type" + // New key supporting more than one type per profile + + private const val VIDEO_PROFILE_TYPES = "video_profile_types_2" private const val DEFAULT_SOURCE_PRIORITY = 1 + /** * Automatically skip loading links once this priority is reached **/ @@ -33,13 +43,14 @@ object QualityDataHelper { 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(R.string.mobile_data, true), + Download(R.string.download, true) } data class QualityProfile( val name: UiText, val id: Int, - val type: QualityProfileType + val types: Set ) fun getSourcePriority(profile: Int, name: String?): Int { @@ -51,8 +62,21 @@ object QualityDataHelper { ) ?: DEFAULT_SOURCE_PRIORITY } + fun getAllSourcePriorityNames(profile: Int): List { + val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" + return getKeys(folder)?.map { key -> + key.substringAfter("$folder/") + } ?: emptyList() + } + fun setSourcePriority(profile: Int, name: String, priority: Int) { - setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) + val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" + // Prevent unnecessary keys + if (priority == DEFAULT_SOURCE_PRIORITY) { + removeKey(folder, name) + } else { + setKey(folder, name, priority) + } } fun setProfileName(profile: Int, name: String?) { @@ -85,16 +109,40 @@ object QualityDataHelper { ) } - fun getQualityProfileType(profile: Int): QualityProfileType { - return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None + + @Suppress("DEPRECATION") + fun getQualityProfileTypes(profile: Int): Set { + val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" + // Use arrays for to make with work with setKey properly (weird crashes otherwise) + val newProfiles = getKey>(newKey)?.toSet() + + // Migrate to new profile key + if (newProfiles == null) { + val oldProfile = + getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") + val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf() + setKey(newKey, newSet) + return newSet.toSet() + } else { + return newProfiles + } } - fun setQualityProfileType(profile: Int, type: QualityProfileType?) { - val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" - if (type == QualityProfileType.None) { - removeKey(path) - } else { - setKey(path, type) + fun addQualityProfileType(profile: Int, type: QualityProfileType) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" + val currentTypes = getQualityProfileTypes(profile) + + if (type != QualityProfileType.None) { + setKey(path, (currentTypes + type).toTypedArray()) + } + } + + fun removeQualityProfileType(profile: Int, type: QualityProfileType) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" + val currentTypes = getQualityProfileTypes(profile) + + if (type != QualityProfileType.None) { + setKey(path, (currentTypes - type).toTypedArray()) } } @@ -106,37 +154,39 @@ object QualityDataHelper { val availableTypes = QualityProfileType.entries.toMutableList() val profiles = (1..PROFILE_COUNT).map { profileNumber -> // Get the real type - val type = getQualityProfileType(profileNumber) + val types = getQualityProfileTypes(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 - } + val uniqueTypes = types.mapNotNull { type -> + // This makes it impossible to get more than one of each type + if (type.unique && !availableTypes.remove(type)) { + null + } else { + type + } + }.toSet() QualityProfile( getProfileName(profileNumber), profileNumber, - uniqueType + uniqueTypes ) }.toMutableList() /** - * If no profile of this type exists: insert it on the earliest profile with None type + * If no profile of this type exists: insert it on the earliest profile **/ 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) - } + if (list.any { it.types.contains(type) }) return + + synchronized(list) { + val firstItem = list.firstOrNull() ?: return + val fixedTypes = firstItem.types + type + val fixedItem = firstItem.copy(types = fixedTypes) + list.set(0, fixedItem) + } } QualityProfileType.entries.forEach { @@ -145,7 +195,7 @@ object QualityDataHelper { debugAssert({ !QualityProfileType.entries.all { type -> - !type.unique || profiles.any { it.type == type } + !type.unique || profiles.any { it.types.contains(type) } } }, { "All unique quality types do not exist" }) @@ -155,4 +205,22 @@ object QualityDataHelper { return profiles } + + fun getLinkPriority( + qualityProfile: Int, + linkData: ExtractorLink? + ): Int { + val qualityPriority = getQualityPriority( + qualityProfile, + closestQuality(linkData?.quality) + ) + val sourcePriority = getSourcePriority(qualityProfile, linkData?.source) + + return qualityPriority + sourcePriority + } + + private fun closestQuality(target: Int?): Qualities { + if (target == null) return Qualities.Unknown + return Qualities.entries.minBy { abs(it.value - target) } + } } \ 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 index 19e98138c..6a0f12e9a 100644 --- 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 @@ -2,47 +2,78 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.app.Dialog import androidx.annotation.StyleRes +import androidx.core.view.isVisible 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.getAllSourcePriorityNames import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles +import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.setText -class QualityProfileDialog( +/** Simplified ExtractorLink for the quality profile dialog */ +data class LinkSource( + val source: String +) { + constructor(extractorLink: ExtractorLink) : this(extractorLink.source) +} + + +class QualityProfileDialog private constructor( val activity: FragmentActivity, @StyleRes val themeRes: Int, - private val links: List, - private val usedProfile: Int, - private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit + private val links: List, + private val usedProfile: Int?, + private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, + private val useProfileSelection: Boolean ) : Dialog(activity, themeRes) { - override fun show() { + constructor( + activity: FragmentActivity, + @StyleRes themeRes: Int, + links: List, + usedProfile: Int, + profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit), + ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) + constructor( + activity: FragmentActivity, + @StyleRes themeRes: Int, + links: List + ) : this(activity, themeRes, links, null, null, false) + + companion object { + // Run on IO as this may be a heavy operation + suspend fun getAllDefaultSources(): List = ioWork { + getProfiles().flatMap { + getAllSourcePriorityNames(it.id) + }.distinct().map { LinkSource(it) } + } + } + + 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*/ + setContentView(binding.root) + fixSystemBarsPadding(binding.root) 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()) + if (usedProfile != null) { + currentlySelectedProfileText.setText(getProfileName(usedProfile)) + } + (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) } profilesRecyclerview.adapter = ProfilesAdapter( - mutableListOf(), usedProfile, ) { oldIndex: Int?, newIndex: Int -> profilesRecyclerview.adapter?.notifyItemChanged(newIndex) @@ -65,37 +96,52 @@ class QualityProfileDialog( setDefaultBtt.setOnClickListener { val currentProfile = getCurrentProfile() ?: return@setOnClickListener - val choices = QualityDataHelper.QualityProfileType.entries - .filter { it != QualityDataHelper.QualityProfileType.None } + val choices = + QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None } val choiceNames = choices.map { txt(it.stringRes).asString(context) } + val selectedIndices = choices.mapIndexed { index, type -> index to type } + .filter { currentProfile.types.contains(it.second) }.map { it.first } - activity.showBottomDialog( + activity.showMultiDialog( choiceNames, - choices.indexOf(currentProfile.type), + selectedIndices, 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) + val pickedChoices = index.mapNotNull { choices.getOrNull(it) } + + pickedChoices.forEach { pickedChoice -> + // Remove previous picks + if (pickedChoice.unique) { + getProfiles().filter { it.types.contains(pickedChoice) }.forEach { + QualityDataHelper.removeQualityProfileType(it.id, pickedChoice) + } } + + QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) } - QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice) refreshProfiles() }) } - cancelBtt.setOnClickListener { - this@QualityProfileDialog.dismissSafe() - } + cancelBtt.isVisible = useProfileSelection + useBtt.isVisible = useProfileSelection + applyBtt.isVisible = !useProfileSelection - useBtt.setOnClickListener { - getCurrentProfile()?.let { - profileSelectionCallback.invoke(it) + if (useProfileSelection) { + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } + + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback?.invoke(it) + this@QualityProfileDialog.dismissSafe() + } + } + } else { + applyBtt.setOnClickListener { this@QualityProfileDialog.dismissSafe() } } 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 index 4c74ec80f..c8ac96ebb 100644 --- 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 @@ -8,14 +8,14 @@ import androidx.appcompat.app.AlertDialog import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding import com.lagradost.cloudstream3.utils.txt -import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SourcePriorityDialog( val ctx: Context, @StyleRes themeRes: Int, - val links: List, + val links: List, private val profile: QualityDataHelper.QualityProfile, /** * Notify that the profile overview should be updated, for example if the name has been updated @@ -24,8 +24,10 @@ class SourcePriorityDialog( private val updatedCallback: () -> Unit ) : Dialog(ctx, themeRes) { override fun show() { - val binding = PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) + val binding = + PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) setContentView(binding.root) + fixSystemBarsPadding(binding.root) val sourcesRecyclerView = binding.sortSources val qualitiesRecyclerView = binding.sortQualities val profileText = binding.profileTextEditable @@ -36,45 +38,46 @@ class SourcePriorityDialog( 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 -> + sourcesRecyclerView.adapter = PriorityAdapter( + ).apply { + submitList(links.map { link -> SourcePriority( null, link.source, QualityDataHelper.getSourcePriority(profile.id, link.source) ) - }.distinctBy { it.name }.sortedBy { -it.priority }.toMutableList() - ) + }.distinctBy { it.name }.sortedBy { -it.priority }) + } - qualitiesRecyclerView.adapter = PriorityAdapter( - Qualities.entries.mapNotNull { + qualitiesRecyclerView.adapter = PriorityAdapter( + ).apply { + submitList(Qualities.entries.mapNotNull { SourcePriority( it, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, QualityDataHelper.getQualityPriority(profile.id, it) ) - }.sortedBy { -it.priority }.toMutableList() - ) + }.sortedBy { -it.priority }) + } @Suppress("UNCHECKED_CAST") // We know the types saveBtt.setOnClickListener { val qualityAdapter = qualitiesRecyclerView.adapter as? PriorityAdapter - val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter + val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter - val qualities = qualityAdapter?.items ?: emptyList() - val sources = sourcesAdapter?.items ?: emptyList() + val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() + val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() qualities.forEach { - val data = it.data as? Qualities ?: return@forEach - QualityDataHelper.setQualityPriority(profile.id, data, it.priority) + QualityDataHelper.setQualityPriority(profile.id, it.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 }) + qualityAdapter?.submitList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.submitList(sources.sortedBy { -it.priority }) val savedProfileName = profileText.text.toString() if (savedProfileName.isBlank()) { 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 12adc0400..cf9bc9975 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 @@ -2,9 +2,7 @@ package com.lagradost.cloudstream3.ui.quicksearch 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 @@ -13,9 +11,9 @@ import android.widget.ImageView import androidx.appcompat.widget.SearchView import androidx.core.view.isGone 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.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CommonActivity.activity @@ -25,28 +23,35 @@ import com.lagradost.cloudstream3.databinding.QuickSearchBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList +import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter 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.setRecycledViewPool 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.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import java.util.concurrent.locks.ReentrantLock -class QuickSearchFragment : Fragment() { +class QuickSearchFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(QuickSearchBinding::inflate) +) { companion object { const val AUTOSEARCH_KEY = "autosearch" const val PROVIDER_KEY = "providers" @@ -85,30 +90,29 @@ class QuickSearchFragment : Fragment() { private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel - var binding: QuickSearchBinding? = null - private var bottomSheetDialog: BottomSheetDialog? = null + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) + + // Fix grid + HomeFragment.currentSpan = view.context.getSpanCount() + binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan + HomeFragment.configEvent.invoke() + } + override fun onCreateView( 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 super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroy() { @@ -130,25 +134,7 @@ class QuickSearchFragment : Fragment() { return false } - private fun fixGrid() { - activity?.getSpanCount()?.let { - HomeFragment.currentSpan = it - } - binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan - HomeFragment.currentSpan = HomeFragment.currentSpan - HomeFragment.configEvent.invoke(HomeFragment.currentSpan) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - fixGrid() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.quickSearchRoot) - fixGrid() - + override fun onBindingCreated(binding: QuickSearchBinding) { arguments?.getStringArray(PROVIDER_KEY)?.let { providers = it.toSet() } @@ -158,55 +144,101 @@ class QuickSearchFragment : Fragment() { getApiFromNameNull(providers?.first())?.hasQuickSearch ?: false } else false - if (isSingleProvider) { - binding?.quickSearchAutofitResults?.apply { + val firstProvider = providers?.firstOrNull() + if (isSingleProvider && firstProvider != null) { + binding.quickSearchAutofitResults.apply { + setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( - ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } } + binding.quickSearchAutofitResults.addOnScrollListener(object : + RecyclerView.OnScrollListener() { + var expandCount = 0 + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + + val adapter = recyclerView.adapter + if (adapter !is SearchAdapter) return + + val count = adapter.itemCount + val currentHasNext = adapter.hasNext + + if (!recyclerView.isRecyclerScrollable() && currentHasNext && expandCount != count) { + expandCount = count + ioSafe { + searchViewModel.expandAndReturn(firstProvider) + } + } + } + }) + try { - binding?.quickSearch?.queryHint = - getString(R.string.search_hint_site).format(providers?.first()) + binding.quickSearch.queryHint = + getString(R.string.search_hint_site).format(firstProvider) } catch (e: Exception) { logError(e) } } else { - binding?.quickSearchMasterRecycler?.adapter = - ParentItemAdapter(fragment = this, id = "quickSearchMasterRecycler".hashCode(), { callback -> - SearchHelper.handleSearchClickCallback(callback) - //when (callback.action) { - //SEARCH_ACTION_LOAD -> { - // clickCallback?.invoke(callback) - //} - // else -> SearchHelper.handleSearchClickCallback(activity, callback) - //} - }, { item -> - bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { - bottomSheetDialog = null + binding.quickSearchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) + binding.quickSearchMasterRecycler.adapter = + ParentItemAdapter( + id = "quickSearchMasterRecycler".hashCode(), + { callback -> + SearchHelper.handleSearchClickCallback(callback) + //when (callback.action) { + //SEARCH_ACTION_LOAD -> { + // clickCallback?.invoke(callback) + //} + // else -> SearchHelper.handleSearchClickCallback(activity, callback) + //} + }, + { item -> + bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { + bottomSheetDialog = null + }, expandCallback = { searchViewModel.expandAndReturn(it) }) + }, + expandCallback = { name -> + ioSafe { + searchViewModel.expandAndReturn(name) + } }) - }) - binding?.quickSearchMasterRecycler?.layoutManager = GridLayoutManager(context, 1) + binding.quickSearchMasterRecycler.layoutManager = GridLayoutManager(context, 1) } - binding?.quickSearchAutofitResults?.isVisible = isSingleProvider - binding?.quickSearchMasterRecycler?.isGone = isSingleProvider + binding.quickSearchAutofitResults.isVisible = isSingleProvider + binding.quickSearchMasterRecycler.isGone = isSingleProvider val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding?.quickSearchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { - updateList(list.map { ongoing -> - val ongoingList = HomePageList( - ongoing.apiName, - if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() + (binding.quickSearchMasterRecycler.adapter as? ParentItemAdapter)?.apply { + val newItems = list.map { ongoing -> + val dataList = ongoing.value.list + val dataListFiltered = + context?.filterSearchResultByFilmQuality(dataList) ?: dataList + + val homePageList = HomePageList( + ongoing.key, + dataListFiltered ) - ongoingList - }) + + val expandableList = HomeViewModel.ExpandableHomepageList( + homePageList, + ongoing.value.currentPage, + ongoing.value.hasNext + ) + + expandableList + } + + submitList(newItems) + //notifyDataSetChanged() } } catch (e: Exception) { logError(e) @@ -216,24 +248,12 @@ class QuickSearchFragment : Fragment() { } val searchExitIcon = - binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + binding.quickSearch.findViewById(androidx.appcompat.R.id.search_close_btn) - //val searchMagIcon = - // binding?.quickSearch?.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() - - activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) - searchExitIcon?.setColorFilter(searchExitIconColor.data) - - binding?.quickSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding.quickSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - UIHelper.hideKeyboard(binding?.quickSearch) + hideKeyboard(binding.quickSearch) return true } @@ -243,41 +263,37 @@ class QuickSearchFragment : Fragment() { return true } }) - binding?.quickSearchLoadingBar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - (binding?.quickSearchAutofitResults?.adapter as? SearchAdapter)?.updateList( - context?.filterSearchResultByFilmQuality(data) ?: data + val adapter = + (binding.quickSearchAutofitResults.adapter as? SearchAdapter) + adapter?.submitList( + context?.filterSearchResultByFilmQuality(data.list) ?: data.list ) + adapter?.hasNext = data.hasNext } searchExitIcon?.alpha = 1f - binding?.quickSearchLoadingBar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding?.quickSearchLoadingBar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f } is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding?.quickSearchLoadingBar?.alpha = 1f + binding.quickSearchLoadingBar.alpha = 1f } } } - - //quick_search.setOnQueryTextFocusChangeListener { _, b -> - // if (b) { - // // https://stackoverflow.com/questions/12022715/unable-to-show-keyboard-automatically-in-the-searchview - // UIHelper.showInputMethod(view.findFocus()) - // } - //} if (isLayout(PHONE or EMULATOR)) { - binding?.quickSearchBack?.apply { + binding.quickSearchBack.apply { isVisible = true setOnClickListener { activity?.popCurrentPage() @@ -286,11 +302,11 @@ class QuickSearchFragment : Fragment() { } if (isLayout(TV)) { - binding?.quickSearch?.requestFocus() + binding.quickSearch.requestFocus() } arguments?.getString(AUTOSEARCH_KEY)?.let { - binding?.quickSearch?.setQuery(it, true) + binding.quickSearch.setQuery(it, true) arguments?.remove(AUTOSEARCH_KEY) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt index b9893193b..056588d0b 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 @@ -6,12 +6,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup 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.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage @@ -19,163 +21,120 @@ import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class ActorAdaptor( private var nextFocusUpId: Int? = null, private val focusCallback: (View?) -> Unit = {} -) : RecyclerView.Adapter() { - data class ActorMetaData( - var isInverted: Boolean, - val actor: ActorData, - ) +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.actor.name == b.actor.name +})) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } - private val actors: MutableList = mutableListOf() + // Easier to store it here than to store it in the ActorData + val inverted: HashMap = hashMapOf() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - focusCallback + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(actors[position].actor, actors[position].isInverted, position) { - actors[position].isInverted = !actors[position].isInverted - this.notifyItemChanged(position) - } + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is CastItemBinding -> { + clearImage(binding.actorImage) } } } - override fun getItemCount(): Int { - return actors.size - } + override fun onBindContent(holder: ViewHolderState, item: ActorData, position: Int) { + when (val binding = holder.view) { + is CastItemBinding -> { + val itemView = binding.root + val isInverted = inverted.getOrDefault(item, false) - private fun updateActorList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - ActorDiffCallback(this.actors, newList) - ) - - actors.clear() - actors.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - fun updateList(newList: List) { - if (actors.size >= newList.size) { - updateActorList(newList.mapIndexed { i, data -> actors[i].copy(actor = data) }) - } else { - updateActorList(newList.mapIndexed { i, data -> - if (i < actors.size) - actors[i].copy(actor = data) - else ActorMetaData(isInverted = false, actor = data) - }) - } - } - - private inner class CardViewHolder( - val binding: CastItemBinding, - private val focusCallback: (View?) -> Unit = {} - ) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { - val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { - Pair(actor.actor.image, actor.voiceActor?.image) - } else { - 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) - } - - itemView.setOnLongClickListener { - if (isLayout(PHONE)) { - Intent(Intent.ACTION_WEB_SEARCH).apply { - putExtra(SearchManager.QUERY, actor.actor.name) - }.also { intent -> - itemView.context.packageManager?.let { pm -> - if (intent.resolveActivity(pm) != null) { - itemView.context.startActivity(intent) - } - } - } - } - true - } - - binding.apply { - actorImage.loadImage(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 - } - } - )?.let { text -> - actorExtra.isVisible = true - 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 + val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { + Pair(item.actor.image, item.voiceActor?.image) } else { - voiceActorName.text = actor.voiceActor?.name - if (!vaImage.isNullOrEmpty()) - voiceActorImageHolder.isVisible = true - voiceActorImage.loadImage(vaImage) + Pair(item.voiceActor?.image, item.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 { + inverted[item] = !isInverted + this.onUpdateContent(holder, getItem(position), position) + } + + itemView.setOnLongClickListener { + if (isLayout(PHONE)) { + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, item.actor.name) + }.also { intent -> + itemView.context.packageManager?.let { pm -> + if (intent.resolveActivity(pm) != null) { + itemView.context.startActivity(intent) + } + } + } + } + true + } + + binding.apply { + actorImage.loadImage(mainImg) + + actorName.text = item.actor.name + item.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 + } + } ?: item.roleString?.let { + actorExtra.isVisible = true + actorExtra.text = it + } ?: run { + actorExtra.isVisible = false + } + + if (item.voiceActor == null) { + voiceActorImageHolder.isVisible = false + voiceActorName.isVisible = false + } else { + voiceActorName.text = item.voiceActor?.name + if (!vaImage.isNullOrEmpty()) + voiceActorImageHolder.isVisible = true + voiceActorImage.loadImage(vaImage) + } } } } } -} - -class ActorDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].actor.actor.name == newList[newItemPosition].actor.actor.name - - 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/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 565c4240d..5e5504164 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 @@ -1,31 +1,37 @@ package com.lagradost.cloudstream3.ui.result -import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.setPadding import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import coil3.dispose import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder 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.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState 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.DownloadClickEvent +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DateFormat @@ -38,7 +44,6 @@ import java.util.Locale * @see VideoClickActionHolder */ const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 - const val ACTION_CHROME_CAST_EPISODE = 4 const val ACTION_CHROME_CAST_MIRROR = 5 @@ -59,83 +64,74 @@ const val ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR = 14 const val ACTION_MARK_AS_WATCHED = 18 const val TV_EP_SIZE = 400 +const val ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE = 19 -data class EpisodeClickEvent(val action: Int, val data: ResultEpisode) +data class EpisodeClickEvent(val position: Int?, val action: Int, val data: ResultEpisode) { + constructor(action: Int, data: ResultEpisode) : this(null, action, data) +} class EpisodeAdapter( private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.id == b.id +}, contentSame = { a, b -> + a == b +})) { companion object { + const val HAS_POSTER: Int = 0 + const val HAS_NO_POSTER: Int = 1 fun getPlayerAction(context: Context): Int { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val playerPref = settingsManager.getString(context.getString(R.string.player_default_key), "") - + val playerPref = + settingsManager.getString(context.getString(R.string.player_default_key), "") + return VideoClickActionHolder.uniqueIdToId(playerPref) ?: ACTION_PLAY_EPISODE_IN_PLAYER } + + val sharedPool = + newSharedPool { + setMaxRecycledViews(HAS_POSTER or CONTENT, 10) + setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) + } } - var cardList: MutableList = mutableListOf() - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + override fun onClearView(holder: ViewHolderState) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } + + when (val binding = holder.view) { + is ResultEpisodeLargeBinding -> { + clearImage(binding.episodePoster) + } + } + super.onClearView(holder) } - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - ResultDiffCallback(this.cardList, newList) - ) - - cardList.clear() - cardList.addAll(newList) - - 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 - - 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*/ + override fun customContentViewType(item: ResultEpisode): Int = + if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) HAS_NO_POSTER else HAS_POSTER + override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { return when (viewType) { - 0 -> { - EpisodeCardViewHolderSmall( + HAS_NO_POSTER -> { + ViewHolderState( ResultEpisodeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ), - hasDownloadSupport, - clickCallback, - downloadClickCallback + ) ) } - 1 -> { - EpisodeCardViewHolderLarge( + HAS_POSTER -> { + ViewHolderState( ResultEpisodeLargeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ), - hasDownloadSupport, - clickCallback, - downloadClickCallback + ) ) } @@ -143,252 +139,223 @@ class EpisodeAdapter( } } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is EpisodeCardViewHolderLarge -> { - holder.bind(getItem(position)) - } + override fun onBindContent(holder: ViewHolderState, item: ResultEpisode, position: Int) { + val itemView = holder.itemView + when (val binding = holder.view) { + is ResultEpisodeLargeBinding -> { + val setWidth = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - is EpisodeCardViewHolderSmall -> { - holder.bind(getItem(position)) - } - } - } + binding.apply { + episodeLinHolder.layoutParams.width = setWidth + episodeHolderLarge.layoutParams.width = setWidth + episodeHolder.layoutParams.width = setWidth - override fun getItemCount(): Int { - return cardList.size - } + if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { + episodeHolderLarge.radius = 0.0f + episodeHolder.setPadding(0) + } - class EpisodeCardViewHolderLarge( - val binding: ResultEpisodeLargeBinding, - private val hasDownloadSupport: Boolean, - private val clickCallback: (EpisodeClickEvent) -> Unit, - private val downloadClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { - var localCard: ResultEpisode? = null + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + DownloadObjects.DownloadEpisodeCached( + name = item.name, + poster = item.poster, + episode = item.episode, + season = item.season, + id = item.id, + parentId = item.parentId, + score = item.score, + description = item.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_EPISODE, + item + ) + ) + } - @SuppressLint("SetTextI18n") - fun bind(card: ResultEpisode) { - localCard = card - val setWidth = - if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_MIRROR, + item + ) + ) + } - binding.episodeLinHolder.layoutParams.width = setWidth - binding.episodeHolderLarge.layoutParams.width = setWidth - binding.episodeHolder.layoutParams.width = setWidth - - - 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, - score = card.score, - description = card.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - 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) + 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 + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) - 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 - } + val name = + if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" + episodeFiller.isVisible = item.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 - episodePoster.loadImage(card.poster) + if (item.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 + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + val displayPos = item.getDisplayPosition() + val durationSec = (item.duration / 1000).toInt() + val progressSec = (displayPos / 1000).toInt() - if (card.score != null) { - episodeRating.text = episodeRating.context?.getString(R.string.rated_format) - ?.format(card.score.toFloat(10)) // TODO Change rated_format to use card.score.toString() - } 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)) + if (displayPos >= item.duration && displayPos > 0) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false } else { - isExpanded = !isExpanded - maxLines = if (isExpanded) { - Integer.MAX_VALUE - } else 4 + episodePlayIcon.setImageResource(R.drawable.netflix_play) + episodeProgress.apply { + max = durationSec + progress = progressSec + isVisible = displayPos > 0L + } } } - } - if (card.airDate != null) { - val isUpcoming = unixTimeMS < card.airDate + val posterVisible = !item.poster.isNullOrBlank() + if (posterVisible) { + val isUpcoming = item.airDate != null && unixTimeMS < item.airDate + episodePoster.loadImage(item.poster) { + if (isUpcoming) { + error { + // If the poster has an url, but it is faulty then + // we use the episodeUpcomingIcon if it is an upcoming episode + main { + // Make sure it is on the main thread + episodeUpcomingIcon.isVisible = true + } - if (isUpcoming) { - episodePlayIcon.isVisible = false - episodeUpcomingIcon.isVisible = !episodePoster.isVisible - episodeDate.setText( - com.lagradost.cloudstream3.utils.txt( - R.string.episode_upcoming_format, - secondsToReadable( - card.airDate.minus(unixTimeMS).div(1000).toInt(), - "" + null // We only care about the runnable + } + } + } + } else { + // Clear the image + episodePoster.dispose() + } + episodePoster.isVisible = posterVisible + + val rating10p = item.score?.toFloat(10) + if (rating10p != null && rating10p > 0.1) { + episodeRating.text = episodeRating.context?.getString(R.string.rated_format) + ?.format(rating10p) // TODO Change rated_format to use card.score.toString() + } else { + episodeRating.text = "" + } + + episodeRating.isGone = episodeRating.text.isNullOrBlank() + + episodeDescript.apply { + text = item.description.html() + isGone = text.isNullOrBlank() + + var isExpanded = false + setOnClickListener { + if (isLayout(TV)) { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_SHOW_DESCRIPTION, + item + ) + ) + } else { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 4 + } + } + } + + if (item.airDate != null) { + val isUpcoming = unixTimeMS < item.airDate + + if (isUpcoming) { + episodeProgress.isVisible = false + episodePlayIcon.isVisible = false + episodeUpcomingIcon.isVisible = !posterVisible + episodeDate.setText( + txt( + R.string.episode_upcoming_format, + secondsToReadable( + item.airDate.minus(unixTimeMS).div(1000).toInt(), + "" + ) ) ) - ) + } else { + episodePlayIcon.isVisible = true + episodeUpcomingIcon.isVisible = false + + val formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).apply { + }.format(Date(item.airDate)) + + episodeDate.setText(txt(formattedAirDate)) + } } else { episodeUpcomingIcon.isVisible = false - - val formattedAirDate = SimpleDateFormat.getDateInstance( - DateFormat.LONG, - Locale.getDefault() - ).apply { - }.format(Date(card.airDate)) - - episodeDate.setText(txt(formattedAirDate)) + episodePlayIcon.isVisible = true + episodeDate.isVisible = false } - } else { - episodeDate.isVisible = false - } - episodeRuntime.setText( - txt( - card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + episodeRuntime.setText( + txt( + item.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 - } - } - } - - 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 - } - } - - 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 - } - - 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, - score = card.score, - description = card.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) + if (isLayout(EMULATOR or PHONE)) { + episodePoster.setOnClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_CLICK_DEFAULT, + item + ) + ) } - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) - } - - else -> { - downloadClickCallback.invoke(it) + episodePoster.setOnLongClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_SHOW_TOAST, + item + ) + ) + return@setOnLongClickListener true } } } - val name = - if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" - episodeFiller.isVisible = card.isFiller == true - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (card.videoWatchState == VideoWatchState.Watched) { - // This cannot be done in getDisplayPosition() as when you have not watched something - // the duration and position is 0 - episodeProgress.max = 1 - episodeProgress.progress = 1 - episodeProgress.isVisible = true - } else { - val displayPos = card.getDisplayPosition() - episodeProgress.max = (card.duration / 1000).toInt() - episodeProgress.progress = (displayPos / 1000).toInt() - episodeProgress.isVisible = displayPos > 0L - } - itemView.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) } if (isLayout(TV)) { @@ -398,29 +365,117 @@ class EpisodeAdapter( } itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) return@setOnLongClickListener true } + } - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + is ResultEpisodeBinding -> { + binding.episodeHolder.layoutParams.apply { + width = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + } + + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + DownloadObjects.DownloadEpisodeCached( + name = item.name, + poster = item.poster, + episode = item.episode, + season = item.season, + id = item.id, + parentId = item.parentId, + score = item.score, + description = item.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_EPISODE, + item + ) + ) + } + + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_MIRROR, + item + ) + ) + } + + else -> { + downloadClickCallback.invoke(it) + } + } + } + + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + + val name = + if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" + episodeFiller.isVisible = item.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 (item.videoWatchState == VideoWatchState.Watched) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + val displayPos = item.getDisplayPosition() + val durationSec = (item.duration / 1000).toInt() + val progressSec = (displayPos / 1000).toInt() + + if (displayPos >= item.duration && displayPos > 0) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + episodePlayIcon.setImageResource(R.drawable.play_button_transparent) + episodeProgress.apply { + max = durationSec + progress = progressSec + isVisible = displayPos > 0L + } + } + } + + itemView.setOnClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_CLICK_DEFAULT, + item + ) + ) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) + return@setOnLongClickListener true + } + + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + } } } } -} - -class ResultDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].id == newList[newItemPosition].id - - 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/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index eecd6262f..54657ed57 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 @@ -2,11 +2,14 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage const val IMAGE_CLICK = 0 const val IMAGE_LONG_CLICK = 1 @@ -16,89 +19,54 @@ class ImageAdapter( val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, ) : - RecyclerView.Adapter() { - private val images: MutableList = mutableListOf() + NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = Int::equals, + contentSame = Int::equals + ) + ) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return ImageViewHolder( - //result_mini_image + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) - // LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ImageViewHolder -> { - holder.bind(images[position], clickCallback, nextFocusUp, nextFocusDown) + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ResultMiniImageBinding ?: return + clearImage(binding.root) + } + + override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { + val binding = holder.view as? ResultMiniImageBinding ?: return + + binding.root.apply { + loadImage(item) + if (nextFocusDown != null) { + this.nextFocusDownId = nextFocusDown } - } - } - - override fun getItemCount(): Int { - return images.size - } - - override fun getItemId(position: Int): Long { - return images[position].toLong() - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - DiffCallback(this.images, newList) - ) - - images.clear() - images.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - class ImageViewHolder(val binding: ResultMiniImageBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind( - img: Int, - clickCallback: ((Int) -> Unit)?, - nextFocusUp: Int?, - nextFocusDown: Int?, - ) { - binding.root.apply { - setImageResource(img) - if (nextFocusDown != null) { - this.nextFocusDownId = nextFocusDown + if (nextFocusUp != null) { + this.nextFocusUpId = nextFocusUp + } + if (clickCallback != null) { + if (isLayout(TV)) { + isClickable = true + isLongClickable = true + isFocusable = true + isFocusableInTouchMode = true } - if (nextFocusUp != null) { - this.nextFocusUpId = nextFocusUp + setOnClickListener { + clickCallback.invoke(IMAGE_CLICK) } - if (clickCallback != null) { - if (isLayout(TV)) { - isClickable = true - isLongClickable = true - isFocusable = true - isFocusableInTouchMode = true - } - setOnClickListener { - clickCallback.invoke(IMAGE_CLICK) - } - setOnLongClickListener { - clickCallback.invoke(IMAGE_LONG_CLICK) - return@setOnLongClickListener true - } + setOnLongClickListener { + clickCallback.invoke(IMAGE_LONG_CLICK) + return@setOnLongClickListener true } } } } -} - -class DiffCallback(private val oldList: List, private val newList: List) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] - - 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/result/LinearListLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt index b4e3062b4..3a0edba2f 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 @@ -8,6 +8,8 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.FocusDirection import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout const val FOCUS_SELF = View.NO_ID - 1 const val FOCUS_INHERIT = FOCUS_SELF - 1 @@ -21,18 +23,17 @@ fun RecyclerView?.setLinearListLayout( ) { if (this == null) return val ctx = this.context ?: 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.layoutManager = (this.layoutManager as? LinearListLayout ?: 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 + } } open class LinearListLayout(context: Context?) : @@ -104,13 +105,33 @@ open class LinearListLayout(context: Context?) : } } + fun redirectRecycleToFirstItem(focused: View): View? { + return when (focused) { + is RecyclerView -> { + (focused.layoutManager as? LinearListLayout)?.let { focusedLayoutManager -> + val firstPosition = focusedLayoutManager.findFirstVisibleItemPosition() + val firstView = focusedLayoutManager.findViewByPosition(firstPosition) + firstView + } ?: focused + } + + else -> focused + } + } + 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_DOWN) getNextDirection( + focused, + FocusDirection.Down + )?.let { newFocus -> + return redirectRecycleToFirstItem(newFocus) } - if (direction == View.FOCUS_UP) getNextDirection(focused, FocusDirection.Up)?.let { newFocus -> - return newFocus + if (direction == View.FOCUS_UP) getNextDirection( + focused, + FocusDirection.Up + )?.let { newFocus -> + return redirectRecycleToFirstItem(newFocus) } if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { @@ -129,10 +150,16 @@ open class LinearListLayout(context: Context?) : } ret } else { - if (direction == View.FOCUS_RIGHT) getNextDirection(focused, FocusDirection.End)?.let { newFocus -> + if (direction == View.FOCUS_RIGHT) getNextDirection( + focused, + FocusDirection.End + )?.let { newFocus -> return newFocus } - if (direction == View.FOCUS_LEFT) getNextDirection(focused, FocusDirection.Start)?.let { newFocus -> + if (direction == View.FOCUS_LEFT) getNextDirection( + focused, + FocusDirection.Start + )?.let { newFocus -> return newFocus } @@ -151,9 +178,15 @@ open class LinearListLayout(context: Context?) : // if out of bounds then refocus as specified return if (lookFor >= itemCount) { - getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down) + getNextDirection( + focused, + if (orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down + ) } else if (lookFor < 0) { - getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up) + getNextDirection( + focused, + if (orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up + ) } else { getViewFromPos(lookFor) ?: run { scrollToPosition(lookFor) @@ -166,6 +199,38 @@ open class LinearListLayout(context: Context?) : } } + override fun requestChildRectangleOnScreen( + parent: RecyclerView, + child: View, + rect: android.graphics.Rect, + immediate: Boolean, + focusedChildVisible: Boolean + ): Boolean { + if (isLayout(TV) && orientation == HORIZONTAL) { + val dx = when { + isLayoutRTL -> getDecoratedRight(child) - (parent.width - parent.paddingRight) + else -> getDecoratedLeft(child) - parent.paddingLeft + } + return if (dx != 0) { + when { + immediate -> parent.scrollBy(dx, 0) + else -> parent.smoothScrollBy(dx, 0) + } + true + } else { + false + } + } else { + return super.requestChildRectangleOnScreen( + parent, + child, + rect, + immediate, + focusedChildVisible + ) + } + } + /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, 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 f0d6a5087..cbf94fd97 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,12 +1,17 @@ package com.lagradost.cloudstream3.ui.result import android.os.Bundle +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager +import coil3.dispose import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SeasonData import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings @@ -14,6 +19,8 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UiImage const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 @@ -53,6 +60,7 @@ data class ResultEpisode( val totalEpisodeIndex: Int? = null, val airDate: Long? = null, val runTime: Int? = null, + val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -90,31 +98,33 @@ fun buildResultEpisode( totalEpisodeIndex: Int? = null, airDate: Long? = null, runTime: Int? = null, + seasonData: SeasonData? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None return ResultEpisode( - headerName, - name, - poster, - episode, - seasonIndex, - season, - data, - apiName, - id, - index, - posDur?.position ?: 0, - posDur?.duration ?: 0, - rating, - description, - isFiller, - tvType, - parentId, - videoWatchState, - totalEpisodeIndex, - airDate, - runTime, + headerName = headerName, + name = name, + poster = poster, + episode = episode, + seasonIndex = seasonIndex, + season = season, + data = data, + apiName = apiName, + id = id, + index = index, + position = posDur?.position ?: 0, + duration = posDur?.duration ?: 0, + score = rating, + description = description, + isFiller = isFiller, + tvType = tvType, + parentId = parentId, + videoWatchState = videoWatchState, + totalEpisodeIndex = totalEpisodeIndex, + airDate = airDate, + runTime = runTime, + seasonData = seasonData ) } @@ -158,7 +168,7 @@ object ResultFragment { fun newInstance( url: String, apiName: String, - name : String, + name: String, startAction: Int = 0, startValue: Int = 0 ): Bundle { @@ -173,9 +183,10 @@ object ResultFragment { } fun updateUI(id: Int? = null) { - // updateUIListener?.invoke() + // updateUIListener?.invoke() updateUIEvent.invoke(id) } + val updateUIEvent = Event() //private var updateUIListener: (() -> Unit)? = null @@ -203,10 +214,7 @@ object ResultFragment { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel super.onResume() - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) - } + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) } override fun onDestroy() { @@ -223,14 +231,52 @@ object ResultFragment { data class StoredData( val url: String, val apiName: String, - val name : String, + val name: String, val showFillers: Boolean, val dubStatus: DubStatus, val start: AutoResume?, val playerAction: Int, - val restart : Boolean, + val restart: Boolean, ) + fun bindLogo( + url: String?, + headers: Map?, + logoView: ImageView, + titleView: TextView + ) { + // Cancel it, as we want to remove the listener onSuccess race condition + logoView.dispose() + + if (url.isNullOrBlank()) { + logoView.isVisible = false + titleView.isVisible = true + return + } + + logoView.isVisible = true + titleView.isVisible = false + + logoView.loadImage( + imageData = UiImage.Image(url, headers = headers), + builder = { + listener( + onSuccess = { _, _ -> + logoView.isVisible = true + titleView.isVisible = false + }, + onError = { _, _ -> + logoView.isVisible = false + titleView.isVisible = true + }, + onCancel = { + // If we manually cancel, then it should not do anything + } + ) + } + ) + } + fun Fragment.getStoredData(): StoredData? { val context = this.context ?: this.activity ?: return null val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -300,8 +346,6 @@ object ResultFragment { context?.updateHasTrailers() activity?.loadCache() - //activity?.fixPaddingStatusbar(result_barstatus) - /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, @@ -311,8 +355,6 @@ object ResultFragment { ) result_back.layoutParams = backParameter*/ - // activity?.fixPaddingStatusbar(result_toolbar) - val storedData = (activity ?: context)?.let { getStoredData(it) } 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 9c39767a2..38b24b265 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 @@ -5,10 +5,10 @@ import android.app.Dialog import android.content.Intent import android.content.res.ColorStateList import android.graphics.Rect +import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Editable -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation @@ -17,18 +17,19 @@ import android.view.animation.DecelerateInterpolator import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope 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.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.APIHolder @@ -39,53 +40,77 @@ import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.base64Encode 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.databinding.TrailerCustomLayoutBinding import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.ui.BaseFragment 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.CS3IPlayer import com.lagradost.cloudstream3.ui.player.CSPlayerEvent -import com.lagradost.cloudstream3.ui.player.FullScreenPlayer +import com.lagradost.cloudstream3.ui.player.IPlayer +import com.lagradost.cloudstream3.ui.player.PlayerView +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo 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.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.SettingsGeneral.Companion.pickDownloadPath +import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher 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.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ImageLoader.loadImage 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.fixSystemBarsPadding 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml +import com.lagradost.cloudstream3.utils.txt +import java.net.URLEncoder +import java.util.concurrent.ConcurrentLinkedDeque import kotlin.math.roundToInt -open class ResultFragmentPhone : FullScreenPlayer() { +open class ResultFragmentPhone : BaseFragment( + BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) +), PlayerView.Callbacks { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -93,48 +118,115 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + /** Queue of pending actions that is deferred to after a custom path is set */ + private val pendingPathActions = ConcurrentLinkedDeque>() + + /** + * Appends all actions to a queue, and asks for a user to enter the download folder if not already set up. + * + * Then processes the queue in the given order, only after the user has selected a folder. + * This is to defer the download to after a file path is set, due to perms. + * */ + private fun requirePathForActions(list: Collection>) { + pendingPathActions.addAll(list) + val (_, path) = context?.getBasePath() ?: return + if (path == null) { + /** If we have not set any download path, then ask the user for it before we download it */ + try { + /** Give the user some info of what we are doing and why, even if it may be missed */ + showToast(R.string.download_path_pref) + pathPicker.launch(Uri.EMPTY) + } catch (t: Throwable) { + logError(t) + /** Something went wrong, TV Device? + * Use the fallback behavior of just downloading it even if no path is selected, + * and hope it works */ + processPendingActions() + } + } else { + /** + * Otherwise dispatch everything, as we already have a valid download path + * Even if this is "wrong", we do not care as the user has entered something + * */ + processPendingActions() + } + } + + /** Clear all the items in the queue and dispatch them to the viewmodel in order */ + private fun processPendingActions() = viewModel.viewModelScope.launchSafe { + while (!pendingPathActions.isEmpty()) { + try { + val (action, data) = pendingPathActions.pop() + viewModel.handleAction( + EpisodeClickEvent( + action, + data + ) + ) + } catch (_: NoSuchElementException) { + /** In case of a race */ + } + } + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + if (uri == null) { + /** No path selected, clear the list without acting on it, canceling */ + if (!pendingPathActions.isEmpty()) { + /** Only show on non-empty, just in case */ + showToast(R.string.download_canceled) + pendingPathActions.clear() + } + } else { + /** Select the folder, and dispatch everything */ + pickDownloadPath(uri, path) + processPendingActions() + } + } + 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 + var player: IPlayer = CS3IPlayer() + protected open var hasPipModeSupport: Boolean = false + protected open var isFullScreenPlayer: Boolean = true + protected open var lockRotation: Boolean = true + protected var playerBinding: TrailerCustomLayoutBinding? = null + protected var isShowing: Boolean = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel = - ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = - ViewModelProvider(this)[SyncViewModel::class.java] - updateUIEvent += ::updateUI + protected var playerHostView: PlayerView? = null - 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 - } + open fun updateUIVisibility() {} - return root + protected fun uiReset() { + isShowing = false + updateUIVisibility() + } + + open fun showMirrorsDialogue() {} + open fun showTracksDialogue() {} + open fun openOnlineSubPicker( + context: android.content.Context, + loadResponse: LoadResponse?, + dismissCallback: () -> Unit + ) {} + + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - PanelsChildGestureRegionObserver.Provider.get().apply { resultBinding?.resultCastItems?.let { register(it) } } } - var currentTrailers: List = emptyList() + var currentTrailers: List> = emptyList() var currentTrailerIndex = 0 override fun nextMirror() { @@ -148,33 +240,35 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player - super.playerError(exception) + playerHostView?.playerError(exception) } else { nextMirror() } } private fun loadTrailer(index: Int? = null) { + val isSuccess = - currentTrailers.getOrNull(index ?: currentTrailerIndex)?.let { trailer -> - context?.let { ctx -> - player.onPause() - player.loadPlayer( - ctx, - false, - trailer, - null, - startPosition = 0L, - subtitles = emptySet(), - subtitle = null, - autoPlay = false, - preview = false - ) - true + currentTrailers.getOrNull(index ?: currentTrailerIndex) + ?.let { (extractedTrailerLink, _) -> + context?.let { ctx -> + player.onPause() + player.loadPlayer( + ctx, + false, + extractedTrailerLink, + null, + startPosition = 0L, + subtitles = emptySet(), + subtitle = null, + autoPlay = false, + preview = false + ) + true + } ?: run { + false + } } ?: run { - false - } - } ?: run { false } //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) @@ -183,6 +277,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { // result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer resultBinding?.apply { + // If we load a trailer, then cancel the big logo and only show the small title + if (isSuccess) { + // This is still a bit of a race condition, but it should work if we have the + // trailers observe after the page observe! + bindLogo( + url = null, + headers = null, + logoView = backgroundPosterWatermarkBadge, + titleView = resultTitle + ) + } resultSmallscreenHolder.isVisible = turnVis resultPosterBackgroundHolder.apply { val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { @@ -218,10 +323,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { //} } - private fun setTrailers(trailers: List?) { + private fun setTrailers(trailers: List>?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return - currentTrailers = trailers?.sortedBy { -it.quality } ?: emptyList() + currentTrailers = trailers?.sortedBy { -it.first.quality } ?: emptyList() loadTrailer() } @@ -235,11 +340,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { } updateUIEvent -= ::updateUI - binding = null + playerHostView?.release() + playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null recommendationBinding = null + activity?.detachBackPressedCallback(this@ResultFragmentPhone.toString()) super.onDestroyView() } @@ -258,7 +365,6 @@ open class ResultFragmentPhone : FullScreenPlayer() { var selectSeason: String? = null var selectEpisodeRange: String? = null - var selectSort: EpisodeSortType? = null private fun setUrl(url: String?) { if (url == null) { @@ -300,10 +406,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel - activity?.let { - @Suppress("DEPRECATION") - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + context?.let { ctx -> + playerHostView?.onResume(ctx) + playerHostView?.setupKeyEventListener() } super.onResume() PanelsChildGestureRegionObserver.Provider.get() @@ -312,25 +418,44 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel + playerHostView?.onStop() super.onStop() } + @Suppress("UNUSED_PARAMETER") private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + // Set up sub-binding references + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI + + resultBinding = binding.fragmentResult + recommendationBinding = binding.resultRecommendations + syncBinding = binding.resultSync + + // Set up trailer player + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = player + playerHostView?.hasPipModeSupport = hasPipModeSupport + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerBinding = binding.root.findViewById(R.id.player_holder)?.let { + TrailerCustomLayoutBinding.bind(it) + } + playerHostView?.initialize() // ===== setup ===== - UIHelper.fixPaddingStatusbar(binding?.resultTopBar) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() - hideKeyboard() + hideKeyboard(binding.root) if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, @@ -348,7 +473,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { // 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( + binding.resultOverlappingPanels.registerEndPanelStateListeners( object : OverlappingPanelsLayout.PanelStateListener { override fun onPanelStateChange(panelState: PanelState) { PanelsChildGestureRegionObserver.Provider.get().apply { @@ -360,8 +485,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { // ===== ===== ===== - binding?.resultSearch?.isGone = storedData.name.isBlank() - binding?.resultSearch?.setOnClickListener { + binding.resultSearch.isGone = storedData.name.isBlank() + binding.resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, storedData.name) } @@ -390,7 +515,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { focused: View? ): Boolean { // Make the cast always focus the first visible item when focused - // from somewhere else. Otherwise it jumps to the last item. + // from somewhere else. Otherwise, it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true @@ -401,13 +526,20 @@ open class ResultFragmentPhone : FullScreenPlayer() { }.apply { this.orientation = RecyclerView.HORIZONTAL }*/ + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor() - + resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( api?.hasDownloadSupport == true, { episodeClick -> - viewModel.handleAction(episodeClick) + when (episodeClick.action) { + ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> { + requirePathForActions(listOf(episodeClick.action to episodeClick.data)) + } + + else -> viewModel.handleAction(episodeClick) + } }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) @@ -430,7 +562,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { activity?.showDialog( names.map { it.second }, viewModel.selectedSortingIndex.value ?: -1, - "", + ctx.getString(R.string.sort_by), false, {}) { itemId -> viewModel.setSort(names[itemId].first) @@ -442,9 +574,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { //check for scroll down - binding?.resultBookmarkFab?.shrink() + binding.resultBookmarkFab.shrink() } else if (dy < -5) { - binding?.resultBookmarkFab?.extend() + binding.resultBookmarkFab.extend() } if (!isFullScreenPlayer && player.getIsPlaying()) { if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height @@ -456,25 +588,37 @@ open class ResultFragmentPhone : FullScreenPlayer() { }) } - binding?.apply { + binding.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { activity?.popCurrentPage() } + activity?.attachBackPressedCallback(this@ResultFragmentPhone.toString()) { + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + runDefault() + } else resultOverlappingPanels.closePanels() + } + resultMiniSync.setOnClickListener { + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openStartPanel() + } else resultOverlappingPanels.closePanels() + } + + /* + resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool) 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() - } + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openStartPanel() + } else resultOverlappingPanels.closePanels() } }) + */ resultSubscribe.setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus @@ -534,12 +678,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { CastContext.getSharedInstance(act.applicationContext) { it.run() }.addOnCompleteListener { - isGone = if (it.isSuccessful) { - it.result.castState == CastState.NO_DEVICES_AVAILABLE - } else { - true - } - + isGone = !it.isSuccessful } // this shit leaks for some reason //castContext.addCastStateListener { state -> @@ -555,8 +694,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { playerBinding?.apply { playerOpenSource.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { - context?.openBrowser(it.url) + currentTrailers.getOrNull(currentTrailerIndex)?.let { (_, ogTrailerLink) -> + context?.openBrowser(ogTrailerLink) } } } @@ -564,9 +703,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { recommendationBinding?.apply { resultRecommendationsList.apply { spanCount = 3 + setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( - ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) @@ -590,10 +729,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultBinding?.apply { if (resume == null) { resultResumeParent.isVisible = false + resultPlayParent.isVisible = true + resultResumeProgressHolder.isVisible = false return@observeNullable } resultResumeParent.isVisible = true resume.progress?.let { progress -> + resultNextSeriesButton.isVisible = false resultResumeSeriesTitle.apply { isVisible = !resume.isMovie text = @@ -603,8 +745,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { resume.result.season ) } - - resultResumeSeriesProgressText.setText(progress.progressLeft) + if (resume.isMovie) { + resultPlayParent.isGone = true + resultResumeSeriesProgressText.isVisible = true + resultResumeSeriesProgressText.setText(progress.progressLeft) + } resultResumeSeriesProgress.apply { isVisible = true this.max = progress.maxProgress @@ -613,25 +758,30 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultResumeProgressHolder.isVisible = true } ?: run { resultResumeProgressHolder.isVisible = false + if (!resume.isMovie) { + resultNextSeriesButton.isVisible = true + resultNextSeriesButton.text = context?.getNameFull( + resume.result.name, + resume.result.episode, + resume.result.season + ) + } 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 - ) - ) + resumeAction(storedData, resume) + } + resultNextSeriesButton.setOnClickListener { + resumeAction(storedData, resume) } } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding?.resultSubscribe?.isVisible = isSubscribed != null + binding.resultSubscribe.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { @@ -640,11 +790,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.baseline_notifications_none_24 } - binding?.resultSubscribe?.setImageResource(drawable) + binding.resultSubscribe.setImageResource(drawable) } observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding?.resultFavorite?.isVisible = isFavorite != null + binding.resultFavorite.isVisible = isFavorite != null if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { @@ -653,11 +803,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { 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! + binding.resultFavorite.setImageResource(drawable) } observeNullable(viewModel.episodes) { episodes -> @@ -665,8 +811,58 @@ open class ResultFragmentPhone : FullScreenPlayer() { // no failure? resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodes.isVisible = episodes is Resource.Success + resultBatchDownloadButton.isVisible = + episodes is Resource.Success && episodes.value.isNotEmpty() + if (episodes is Resource.Success) { - (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) + (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + + // Show quality dialog with all sources + resultBatchDownloadButton.setOnLongClickListener { + ioSafe { + val defaultSources = QualityProfileDialog.getAllDefaultSources() + val activity = activity ?: return@ioSafe + activity.runOnUiThread { + QualityProfileDialog( + activity, + R.style.DialogFullscreenPlayer, + defaultSources, + ).show() + } + } + + true + } + + resultBatchDownloadButton.setOnClickListener { view -> + val episodeStart = + episodes.value.firstOrNull()?.episode ?: return@setOnClickListener + val episodeEnd = + episodes.value.lastOrNull()?.episode ?: return@setOnClickListener + + val episodeRange = if (episodeStart == episodeEnd) { + episodeStart.toString() + } else { + txt( + R.string.episodes_range, + episodeStart, + episodeEnd + ).asString(view.context) + } + + val rangeMessage = txt( + R.string.download_episode_range, + episodeRange + ).asString(view.context) + + AlertDialog.Builder(view.context, R.style.AlertDialogCustom) + .setTitle(R.string.download_all) + .setMessage(rangeMessage) + .setPositiveButton(R.string.yes) { _, _ -> + requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it }) + } + .setNegativeButton(R.string.cancel) { _, _ -> }.show() + } } } } @@ -690,8 +886,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { ) return@setOnLongClickListener true } + resultResumeSeriesButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) + ) + return@setOnLongClickListener true + } + + val status = VideoDownloadManager.downloadStatus[ep.id] + downloadButton.setStatus(status) downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = ep.name, poster = ep.poster, episode = 0, @@ -708,18 +913,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { when (click.action) { DOWNLOAD_ACTION_DOWNLOAD -> { - viewModel.handleAction( - EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep)) } DOWNLOAD_ACTION_LONG_CLICK -> { - viewModel.handleAction( - EpisodeClickEvent( - ACTION_DOWNLOAD_MIRROR, - ep - ) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep)) } else -> DownloadButtonSetup.handleDownloadClick(click) @@ -770,6 +968,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + bindLogo( + url = d.logoUrl, + headers = d.posterHeaders, + titleView = resultTitle, + logoView = backgroundPosterWatermarkBadge + ) + var isExpanded = false resultDescription.apply { setTextHtml(d.plotText) @@ -786,8 +991,15 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon - resultCastItems.isGone = d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.updateList(d.actors ?: emptyList()) + val prefs = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) + val showCast = prefs.getBoolean( + root.context.getString(R.string.show_cast_in_details_key), + true + ) + + resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap @@ -801,7 +1013,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncModel.addFromUrl(d.url) } - binding?.apply { + binding.apply { resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) @@ -810,15 +1022,23 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultShare.setOnClickListener { try { val i = Intent(Intent.ACTION_SEND) + val nameBase64 = + base64Encode(d.apiName.toString().toByteArray(Charsets.UTF_8)) + val urlBase64 = base64Encode(d.url.toByteArray(Charsets.UTF_8)) + val encodedUri = URLEncoder.encode( + "$APP_STRING_SHARE:$nameBase64?$urlBase64", + "UTF-8" + ) + val redirectUrl = + "https://recloudstream.github.io/csredirect?redirectto=$encodedUri" i.type = "text/plain" i.putExtra(Intent.EXTRA_SUBJECT, d.title) - i.putExtra(Intent.EXTRA_TEXT, d.url) + i.putExtra(Intent.EXTRA_TEXT, redirectUrl) startActivity(Intent.createChooser(i, d.title)) } catch (e: Exception) { logError(e) } } - setUrl(d.url) resultBookmarkFab.apply { isVisible = true @@ -828,10 +1048,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { } (data as? Resource.Failure)?.let { data -> + @SuppressLint("SetTextI18n") resultErrorText.text = storedData.url.plus("\n") + data.errorString } - binding?.resultBookmarkFab?.isVisible = data is Resource.Success + binding.resultBookmarkFab.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading @@ -878,14 +1099,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + observe(viewModel.trailers) { trailers -> + setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet! + } + 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 }) + binding.resultMiniSync.isVisible = newList.isNotEmpty() } @@ -980,7 +1204,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } - binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -1009,14 +1233,14 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncBinding?.apply { resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE resultSyncCheck.adapter = arrayAdapter - UIHelper.setListViewHeightBasedOnItems(resultSyncCheck) + setListViewHeightBasedOnItems(resultSyncCheck) resultSyncCheck.setOnItemClickListener { _, _, which, _ -> syncModel.setStatus(which - 1) } resultSyncRating.addOnChangeListener { it, value, fromUser -> - if(fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) + if (fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) } resultSyncAddEpisode.setOnClickListener { @@ -1041,7 +1265,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.watchStatus) { watchType -> - binding?.resultBookmarkFab?.apply { + binding.resultBookmarkFab.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) @@ -1096,6 +1320,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { viewModel.skipLoading() } isVisible = true + @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } @@ -1165,7 +1390,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectEpisodeRange }, - "", + ctx.getString(R.string.episodes), false, {}) { itemId -> viewModel.changeRange(names[itemId].first) @@ -1186,7 +1411,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectSeason }, - "", + ctx.getString(R.string.season), false, {}) { itemId -> viewModel.changeSeason(names[itemId].first) @@ -1203,7 +1428,20 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + private fun resumeAction( + storedData: ResultFragment.StoredData, + resume: ResumeWatchingStatus + ) { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) + } + override fun onPause() { + playerHostView?.releaseKeyEventListener() super.onPause() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) @@ -1217,7 +1455,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { root.isGone = isInvalid root.post { rec?.let { list -> - (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) } } } @@ -1261,4 +1499,4 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } -} \ No newline at end of file +} 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 e5ca2e4e1..cfbacc5d1 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 @@ -14,7 +14,6 @@ import androidx.core.view.isGone import androidx.core.view.isInvisible 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 @@ -30,20 +29,24 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.ui.BaseFragment 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.bindLogo 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.setRecycledViewPool 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.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache @@ -53,21 +56,24 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.UIHelper.populateChips +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml +import com.lagradost.cloudstream3.utils.txt + +class ResultFragmentTv : BaseFragment( + BindingCreator.Inflate(FragmentResultTvBinding::inflate) +) { -class ResultFragmentTv : Fragment() { private lateinit var viewModel: ResultViewModel2 - private var binding: FragmentResultTvBinding? = null override fun onDestroyView() { - binding = null updateUIEvent -= ::updateUI activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) super.onDestroyView() @@ -77,15 +83,13 @@ class ResultFragmentTv : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): 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 + return super.onCreateView(inflater, container, savedInstanceState) } private fun updateUI(id: Int?) { @@ -119,7 +123,7 @@ class ResultFragmentTv : Fragment() { } private fun RecyclerView?.update(data: List) { - (this?.adapter as? SelectAdaptor?)?.updateSelectionList(data) + (this?.adapter as? SelectAdaptor?)?.submitList(data) this?.isVisible = data.size > 1 } @@ -152,14 +156,14 @@ class ResultFragmentTv : Fragment() { resultRecommendationsList.isGone = isInvalid resultRecommendationsHolder.isGone = isInvalid val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst } + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(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 { - com.lagradost.cloudstream3.utils.txt( + txt( it ) to it }) @@ -188,11 +192,7 @@ class ResultFragmentTv : Fragment() { } override fun onResume() { - activity?.let { - @Suppress("DEPRECATION") - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) - } + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) afterPluginsLoadedEvent += ::reloadViewModel super.onResume() } @@ -250,10 +250,12 @@ class ResultFragmentTv : Fragment() { } } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + fixSystemBarsPadding(view, padTop = false) + } + @SuppressLint("SetTextI18n") + override fun onBindingCreated(binding: FragmentResultTvBinding) { // ===== setup ===== val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() @@ -271,7 +273,7 @@ class ResultFragmentTv : Fragment() { // ===== ===== ===== var comingSoon = false - binding?.apply { + binding.apply { //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f // parallax on background @@ -283,7 +285,7 @@ class ResultFragmentTv : Fragment() { if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(false) - binding?.apply { + binding.apply { val views = listOf( resultPlayMovieButton, resultPlaySeriesButton, @@ -304,7 +306,7 @@ class ResultFragmentTv : Fragment() { redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(true) - binding?.apply { + binding.apply { val views = listOf( resultDubSelection, resultSeasonSelection, @@ -408,24 +410,24 @@ class ResultFragmentTv : Fragment() { resultCastItems.setOnFocusChangeListener { _, hasFocus -> // Always escape focus - if (hasFocus) binding?.resultBookmarkButton?.requestFocus() + if (hasFocus) binding.resultBookmarkButton.requestFocus() } //resultBack.setOnClickListener { // activity?.popCurrentPage() //} resultRecommendationsList.spanCount = 8 + resultRecommendationsList.setRecycledViewPool(SearchAdapter.sharedPool) resultRecommendationsList.adapter = SearchAdapter( - ArrayList(), resultRecommendationsList, ) { callback -> - if (callback.action == SEARCH_ACTION_FOCUSED) + if (callback.action == SEARCH_ACTION_FOCUSED) { toggleEpisodes(false) - else - SearchHelper.handleSearchClickCallback(callback) + } else SearchHelper.handleSearchClickCallback(callback) } + resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( false, @@ -437,8 +439,7 @@ class ResultFragmentTv : Fragment() { } ) - resultCastItems.layoutManager = object : LinearListLayout(view.context) { - + resultCastItems.layoutManager = object : LinearListLayout(root.context) { override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, @@ -454,35 +455,48 @@ class ResultFragmentTv : Fragment() { super.onRequestChildFocus(parent, state, child, focused) } } - }.apply { - setHorizontal() - } + }.apply { setHorizontal() } val aboveCast = listOf( - binding?.resultEpisodesShow, - binding?.resultBookmark, - binding?.resultFavorite, - binding?.resultSubscribe, - ).firstOrNull { - it?.isVisible == true - } + binding.resultEpisodesShow, + binding.resultBookmark, + binding.resultFavorite, + binding.resultSubscribe, + ).firstOrNull { it.isVisible } + + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { toggleEpisodes(false) } + + if (isLayout(EMULATOR)) { + episodesShadow.setOnClickListener { + toggleEpisodes(false) + } + } } observeNullable(viewModel.resumeWatching) { resume -> - binding?.apply { - + binding.apply { if (resume == null) { return@observeNullable } + resultResumeSeries.isVisible = true resultPlayMovie.isVisible = false resultPlaySeries.isVisible = false // show progress no matter if series or movie resume.progress?.let { progress -> + resultResumeSeriesTitle.apply { + isVisible = !resume.isMovie + text = + if (resume.isMovie) null else context?.getNameFull( + resume.result.name, + resume.result.episode, + resume.result.season + ) + } resultResumeSeriesProgressText.setText(progress.progressLeft) resultResumeSeriesProgress.apply { isVisible = true @@ -534,17 +548,18 @@ class ResultFragmentTv : Fragment() { observe(viewModel.trailers) { trailersLinks -> context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return@observe - val trailers = trailersLinks.flatMap { it.mirros } - binding?.apply { - resultPlayTrailer.isGone = trailers.isEmpty() + val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } + .map { (extractedTrailerLink, _) -> extractedTrailerLink } + binding.apply { + resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() resultPlayTrailerButton.setOnClickListener { - if (trailers.isEmpty()) return@setOnClickListener + if (extractedTrailerLinks.isEmpty()) return@setOnClickListener activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( ExtractorLinkGenerator( - trailers, + extractedTrailerLinks, emptyList() - ) + ), 0 ) ) } @@ -552,16 +567,13 @@ class ResultFragmentTv : Fragment() { } observe(viewModel.watchStatus) { watchType -> - binding?.apply { + 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 - } + } else R.drawable.ic_baseline_bookmark_24 setIconResource(drawable) setOnClickListener { view -> @@ -579,19 +591,13 @@ class ResultFragmentTv : Fragment() { } observeNullable(viewModel.favoriteStatus) { isFavorite -> - - binding?.resultFavorite?.isVisible = isFavorite != null - - binding?.resultFavoriteButton?.apply { - + 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 - } - + } else R.drawable.ic_baseline_favorite_border_24 setIconResource(drawable) setOnClickListener { @@ -600,15 +606,13 @@ class ResultFragmentTv : Fragment() { val message = if (newStatus) { R.string.favorite_added - } else { - R.string.favorite_removed - } + } else R.string.favorite_removed val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) + ?: txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( - com.lagradost.cloudstream3.utils.txt( + txt( message, name ), Toast.LENGTH_SHORT @@ -617,28 +621,22 @@ class ResultFragmentTv : Fragment() { } } - binding?.resultFavoriteText?.apply { + binding.resultFavoriteText.apply { val text = if (isFavorite == true) { R.string.unfavorite - } else { - R.string.favorite - } + } else R.string.favorite setText(text) } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding?.resultSubscribe?.isVisible = isSubscribed != null && isLayout(EMULATOR) - binding?.resultSubscribeButton?.apply { - + 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 - } - + } else R.drawable.baseline_notifications_none_24 setIconResource(drawable) setOnClickListener { @@ -649,15 +647,13 @@ class ResultFragmentTv : Fragment() { // Kinda icky to have this here, but it works. SubscriptionWorkManager.enqueuePeriodicWork(context) R.string.subscription_new - } else { - R.string.subscription_deleted - } + } else R.string.subscription_deleted val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) + ?: txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( - com.lagradost.cloudstream3.utils.txt( + txt( message, name ), Toast.LENGTH_SHORT @@ -665,12 +661,10 @@ class ResultFragmentTv : Fragment() { } } - binding?.resultSubscribeText?.apply { + binding.resultSubscribeText.apply { val text = if (isSubscribed) { R.string.action_unsubscribe - } else { - R.string.action_subscribe - } + } else R.string.action_subscribe setText(text) } } @@ -681,10 +675,8 @@ class ResultFragmentTv : Fragment() { return@observeNullable } - binding?.apply { - + binding.apply { (data as? Resource.Success)?.value?.let { (_, ep) -> - resultPlayMovieButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) @@ -698,10 +690,9 @@ class ResultFragmentTv : Fragment() { } resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone - if (comingSoon) + if (comingSoon) { resultBookmarkButton.requestFocus() - else - resultPlayMovieButton.requestFocus() + } else resultPlayMovieButton.requestFocus() // Stops last button right focus resultSearchButton.nextFocusRightId = R.id.result_search_Button @@ -770,26 +761,26 @@ class ResultFragmentTv : Fragment() { observeNullable(viewModel.episodesCountText) { count -> - binding?.resultEpisodesText.setText(count) + binding.resultEpisodesText.setText(count) } observe(viewModel.selectedRangeIndex) { selected -> - binding?.resultRangeSelection.select(selected) + binding.resultRangeSelection.select(selected) } observe(viewModel.selectedSeasonIndex) { selected -> - binding?.resultSeasonSelection.select(selected) + binding.resultSeasonSelection.select(selected) } observe(viewModel.selectedDubStatusIndex) { selected -> - binding?.resultDubSelection.select(selected) + binding.resultDubSelection.select(selected) } observe(viewModel.rangeSelections) { - binding?.resultRangeSelection.update(it) + binding.resultRangeSelection.update(it) } observe(viewModel.dubSubSelections) { - binding?.resultDubSelection.update(it) + binding.resultDubSelection.update(it) } observe(viewModel.seasonSelections) { - binding?.resultSeasonSelection.update(it) + binding.resultSeasonSelection.update(it) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -797,7 +788,7 @@ class ResultFragmentTv : Fragment() { if (isLayout(TV)) { observe(viewModel.episodeSynopsis) { description -> - view.context?.let { ctx -> + context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(description.html()) @@ -814,15 +805,11 @@ class ResultFragmentTv : Fragment() { var hasLoadedEpisodesOnce = false observeNullable(viewModel.episodes) { episodes -> if (episodes == null) return@observeNullable - - binding?.apply { - - if (comingSoon) - resultBookmarkButton.requestFocus() + 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 } @@ -865,14 +852,14 @@ class ResultFragmentTv : Fragment() { } - (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) + (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) } } } observeNullable(viewModel.page) { data -> if (data == null) return@observeNullable - binding?.apply { + binding.apply { when (data) { is Resource.Success -> { val d = data.value @@ -902,7 +889,7 @@ class ResultFragmentTv : Fragment() { Integer.MAX_VALUE } else 10 } else { - view.context?.let { ctx -> + context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(d.plotText.asString(ctx).html()) @@ -922,21 +909,32 @@ class ResultFragmentTv : Fragment() { R.drawable.profile_bg_red, R.drawable.profile_bg_teal ).random() - //Change poster crop area to 20% from Top - backgroundPoster.cropYCenterOffsetPct = 0.20F backgroundPoster.loadImage(d.posterBackgroundImage) { error { getImageFromDrawable(context ?: return@error null, error) } } + + bindLogo( + url = d.logoUrl, + headers = d.posterHeaders, + titleView = resultTitle, + logoView = backgroundPosterWatermarkBadgeHolder + ) + 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() + populateChips(resultTag, d.tags) + val prefs = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) + val showCast = prefs.getBoolean( + root.context.getString(R.string.show_cast_in_details_key), + true ) + resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) + if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap resultMetaContentRating.width = 0 @@ -947,9 +945,7 @@ class ResultFragmentTv : Fragment() { } } - is Resource.Loading -> { - - } + is Resource.Loading -> {} is Resource.Failure -> { resultErrorText.text = @@ -966,4 +962,4 @@ class ResultFragmentTv : Fragment() { } } } -} \ 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 5253974e8..3b1471e6a 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,40 +3,76 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration +import android.os.Build import android.os.Bundle -import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.core.view.ViewCompat 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.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.ui.player.CSPlayerEvent +import com.lagradost.cloudstream3.ui.player.CSPlayerLoading 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.UIHelper.fixSystemBarsPadding -open class ResultTrailerPlayer : ResultFragmentPhone() { +class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { - const val TAG = "RESULT_TRAILER" + const val TAG = "ResultTrailerPlayer" } private var playerWidthHeight: Pair? = null + private var introVisible = true + + // Single-tap on empty player area: toggle controls. + override fun onSingleTap() { + if (introVisible) return + if (isShowing) uiReset() else showControls() + } + + private fun showControls() { + if (introVisible) return + isShowing = true + updateUIVisibility() + playerHostView?.scheduleAutoHide() + } + + override fun isUIShowing(): Boolean = isShowing + + override fun onAutoHideUI() { + if (player.getIsPlaying()) uiReset() + } + + override fun onHidePlayerUI() = uiReset() + + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) uiReset() + } + + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + showControls() + } else playerHostView?.scheduleAutoHide() + } override fun nextEpisode() {} - override fun prevEpisode() {} - - override fun playerPositionChanged(position: Long, duration : Long) {} - + override fun playerPositionChanged(position: Long, duration: Long) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -46,18 +82,28 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } private fun fixPlayerSize() { + binding?.apply { + if (isFullScreenPlayer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ViewCompat.setOnApplyWindowInsetsListener(root, null) + root.overlay.clear() + } + root.setPadding(0, 0, 0, 0) + } else { + fixSystemBarsPadding(root) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ViewCompat.requestApplyInsets(root) + } + } + } + playerWidthHeight?.let { (w, h) -> - if(w <= 0 || h <= 0) return@let + if (w <= 0 || h <= 0) return@let val orientation = context?.resources?.configuration?.orientation ?: return - val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - screenWidth - } else { - screenHeight - } + val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight - //result_trailer_loading?.isVisible = false resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer @@ -65,35 +111,30 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to + ) } playerBinding?.playerIntroPlay?.apply { - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight - ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { - val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) - anim.addUpdateListener { valueAnimator -> - val `val` = valueAnimator.animatedValue as Int - val layoutParams: ViewGroup.LayoutParams = - layoutParams - layoutParams.height = `val` - setLayoutParams(layoutParams) + anim.addUpdateListener { va -> + val v = va.animatedValue as Int + val lp: ViewGroup.LayoutParams = layoutParams + lp.height = v + layoutParams = lp } anim.duration = 200 anim.start() @@ -102,9 +143,14 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } } - override fun playerDimensionsLoaded(width: Int, height : Int) { + override fun playerDimensionsLoaded(width: Int, height: Int) { playerWidthHeight = width to height fixPlayerSize() + // Apply autorotation when fullscreen (lockRotation = true). + // PlayerView already set isVerticalOrientation before this callback fires. + if (lockRotation) { + activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return + } } override fun showMirrorsDialogue() {} @@ -114,33 +160,39 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) { - } + ) {} override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} + + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } + private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen + playerHostView?.isFullScreen = fullscreen - playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + playerBinding?.playerFullscreen?.setImageResource( + if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 + ) if (fullscreen) { - enterFullscreen() + playerHostView?.enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } - resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } - } else { binding?.apply { resultTopBar.isVisible = true @@ -151,36 +203,55 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - exitFullscreen() + playerHostView?.exitFullscreen() } fixPlayerSize() uiReset() if (isFullScreenPlayer) { - activity?.attachBackPressedCallback("ResultTrailerPlayer") { - updateFullscreen(false) - } - } else activity?.detachBackPressedCallback("ResultTrailerPlayer") + activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } + } else { + activity?.detachBackPressedCallback("ResultTrailerPlayer") + } } override fun updateUIVisibility() { super.updateUIVisibility() - playerBinding?.playerGoBackHolder?.isVisible = false + playerBinding?.apply { + playerGoBackHolder.isVisible = false + val controlsVisible = isShowing && !introVisible + playerTopHolder.isVisible = controlsVisible + playerVideoHolder.isVisible = controlsVisible + shadowOverlay.isVisible = controlsVisible + playerPausePlayHolderHolder.isVisible = + controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering + } + // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. + playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - playerBinding?.playerFullscreen?.setOnClickListener { - updateFullscreen(!isFullScreenPlayer) + override fun playerStatusChanged() { + if (introVisible) { + playerBinding?.playerPausePlayHolderHolder?.isVisible = false } + } + + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true + introVisible = false player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) - updateUIVisibility() fixPlayerSize() + showControls() } } -} \ 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 c445c49a1..c519e0de2 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 @@ -1,7 +1,8 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import android.content.* +import android.content.Context +import android.content.DialogInterface import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -10,33 +11,67 @@ 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 import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.IDownloadableMinimum +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId 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 +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.ProviderType +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.SimklSyncServices +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TrackerType +import com.lagradost.cloudstream3.TrailerData +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.VPNStatus import com.lagradost.cloudstream3.actions.AlwaysAskAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.metaproviders.SyncRedirector -import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugException +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.runAllAsync +import com.lagradost.cloudstream3.sortUrls 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.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_ALL import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP @@ -44,18 +79,23 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD 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.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs +import com.lagradost.cloudstream3.utils.AppUtils.toJson 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.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.editor +import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites @@ -81,8 +121,30 @@ 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.Editor +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.FillerEpisodeCheck +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.navigate -import kotlinx.coroutines.* +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit /** This starts at 1 */ @@ -115,6 +177,7 @@ data class ResultData( val posterImage: String?, val posterBackgroundImage: String?, + val logoUrl: String?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, @@ -240,6 +303,7 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { plot!! ), backgroundPosterUrl = backgroundPosterUrl, + logoUrl = logoUrl, title = name, typeText = txt( when (type) { @@ -255,11 +319,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.Music -> R.string.music_singular TvType.AudioBook -> R.string.audio_book_singular - TvType.CustomMedia -> R.string.custom_media_singluar - TvType.Audio -> R.string.audio_singluar - TvType.Podcast -> R.string.podcast_singluar + TvType.CustomMedia -> R.string.custom_media_singular + TvType.Audio -> R.string.audio_singular + TvType.Podcast -> R.string.podcast_singular + TvType.Video -> R.string.video_singular } ), yearText = txt(year?.toString()), @@ -333,6 +398,7 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, + val syncData: HashMap ) sealed class SelectPopup { @@ -383,7 +449,7 @@ fun SelectPopup.getOptions(context: Context): List { } data class ExtractedTrailerData( - var mirros: List, + var mirros: List>,//Pair of extracted trailer link and original trailer link var subtitles: List = emptyList(), ) @@ -413,8 +479,8 @@ class ResultViewModel2 : ViewModel() { private var currentShowFillers: Boolean = false var currentRepo: APIRepository? = null private var currentId: Int? = null - private var fillers: Map = emptyMap() - private var generator: IGenerator? = null + private var fillers: HashSet = hashSetOf() + private var generator: RepoLinkGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -518,6 +584,29 @@ class ResultViewModel2 : ViewModel() { return this?.firstOrNull { it.season == season } } + fun seasonToTxt(seasonData: SeasonData?, season: Int?): UiText? { + if (season == 0) { + return txt(R.string.no_season) + } + + // If displaySeason is null then only show the name! + return 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 ?: season, + suffix + ) + } + } + + private fun List?.getSeasonTxt(season: Int?): UiText? = + seasonToTxt(getSeason(season), season) + + private fun filterName(name: String?): String? { if (name == null) return null Regex("^[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { @@ -636,226 +725,6 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } - - private fun downloadSubtitle( - context: Context?, - link: ExtractorSubtitleLink, - fileName: String, - folder: String - ) { - ioSafe { - VideoDownloadManager.downloadThing( - context ?: return@ioSafe, - link, - "$fileName ${link.name}", - folder, - if (link.url.contains(".srt")) "srt" else "vtt", - false, - null, createNotificationCallback = {} - ) - } - } - - private fun getFolder(currentType: TvType, titleName: String): String { - return if (currentType.isEpisodeBased()) { - val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) - "${currentType.getFolderPrefix()}/$sanitizedFileName" - } else currentType.getFolderPrefix() - } - - private fun downloadSubtitle( - context: Context?, - link: SubtitleData, - meta: VideoDownloadManager.DownloadEpisodeMetadata, - ) { - context?.let { ctx -> - val fileName = VideoDownloadManager.getFileName(ctx, meta) - val folder = getFolder(meta.type ?: return, meta.mainName) - downloadSubtitle( - ctx, - ExtractorSubtitleLink(link.name, link.url, "", link.headers), - fileName, - folder - ) - } - } - - fun startDownload( - context: Context?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - links: List, - subs: List? - ) { - try { - if (context == null) return - - val meta = - getMeta( - episode, - currentHeaderName, - apiName, - currentPoster, - currentIsMovie, - currentType - ) - - val folder = getFolder(currentType, currentHeaderName) - - val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let - - // SET VISUAL KEYS - setKey( - DOWNLOAD_HEADER_CACHE, - parentId.toString(), - VideoDownloadHelper.DownloadHeaderCached( - apiName = apiName, - url = url, - type = currentType, - name = currentHeaderName, - poster = currentPoster, - id = parentId, - cacheTime = System.currentTimeMillis(), - ) - ) - - setKey( - DataStore.getFolderName( - DOWNLOAD_EPISODE_CACHE, - parentId.toString() - ), // 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, - score = episode.score, - description = episode.description, - cacheTime = System.currentTimeMillis(), - ) - ) - - // DOWNLOAD VIDEO - VideoDownloadManager.downloadEpisodeUsingWorker( - context, - src,//url ?: return, - folder, - meta, - links - ) - - // 1. Checks if the lang should be downloaded - // 2. Makes it into the download format - // 3. Downloads it as a .vtt file - val downloadList = SubtitlesFragment.getDownloadSubsLanguageISO639_1() - subs?.let { subsList -> - subsList.filter { - downloadList.contains( - SubtitleHelper.fromLanguageToTwoLetters( - it.name, - true - ) - ) - } - .map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) }.take(3) - .forEach { link -> - val fileName = VideoDownloadManager.getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } - } - } catch (e: Exception) { - logError(e) - } - } - - suspend fun downloadEpisode( - activity: Activity?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - ) { - ioSafe { - val generator = RepoLinkGenerator(listOf(episode)) - val currentLinks = mutableSetOf() - val currentSubs = mutableSetOf() - generator.generateLinks( - clearCache = false, - allowedTypes = LOADTYPE_INAPP_DOWNLOAD, - callback = { - it.first?.let { link -> - currentLinks.add(link) - } - }, - subtitleCallback = { sub -> - currentSubs.add(sub) - }) - - if (currentLinks.isEmpty()) { - main { - showToast( - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - return@ioSafe - } else { - main { - showToast( - R.string.download_started, - Toast.LENGTH_SHORT - ) - } - } - - startDownload( - activity, - episode, - currentIsMovie, - currentHeaderName, - currentType, - currentPoster, - apiName, - parentId, - url, - sortUrls(currentLinks), - sortSubs(currentSubs), - ) - } - } - - private fun getMeta( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): VideoDownloadManager.DownloadEpisodeMetadata { - return VideoDownloadManager.DownloadEpisodeMetadata( - episode.id, - VideoDownloadManager.sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, - ) - } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) @@ -1034,6 +903,28 @@ class ResultViewModel2 : ViewModel() { } } + private fun getMeta( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadObjects.DownloadEpisodeMetadata { + return DownloadObjects.DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + /** * Toggles the favorite status of an item. * @@ -1337,7 +1228,7 @@ class ResultViewModel2 : ViewModel() { // TODO Add skip loading here loadLinks(result, isVisible = true, sourceTypes, isCasting = isCasting) { links -> // Could not find a better way to do this - //val context = AcraApplication.context + //val context = CloudStreamApp.context postPopup( text, links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") } @@ -1391,7 +1282,7 @@ class ResultViewModel2 : ViewModel() { updatePage() tempGenerator.generateLinks( clearCache, - allowedTypes = sourceTypes, + sourceTypes = sourceTypes, callback = { (link, _) -> if (link != null) { links += link @@ -1402,9 +1293,10 @@ class ResultViewModel2 : ViewModel() { subs += sub updatePage() }, - isCasting = isCasting + isCasting = isCasting, + offset = 0 ) - } catch (e: CancellationException) { + } catch (_: CancellationException) { // Do nothing } catch (e: Exception) { logError(e) @@ -1412,7 +1304,11 @@ class ResultViewModel2 : ViewModel() { _loadedLinks.postValue(null) } - return LinkLoadingResult(sortUrls(links), sortSubs(subs)) + return LinkLoadingResult( + sortUrls(links), + sortSubs(subs), + HashMap(currentResponse?.syncData ?: emptyMap()) + ) } fun handleAction(click: EpisodeClickEvent) = @@ -1424,6 +1320,40 @@ class ResultViewModel2 : ViewModel() { _episodeSynopsis.postValue(null) } + private fun markEpisodes( + editor: Editor, + episodeIds: Array, + watchState: VideoWatchState + ) { + val watchStateString = watchState.toJson() + episodeIds.forEach { + if (getVideoWatchState(it.toInt()) != watchState) { + editor.setKeyRaw( + getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), + watchStateString + ) + } + } + } + + private fun getEpisodesIdsBySeason(season: Int): HashMap> { + val result = currentEpisodes.entries + .asSequence() + .filter { it.key.season <= season && it.key.dubStatus == preferDubStatus } + .flatMap { entry -> + entry.value.asSequence().map { entry.key.season to it.id.toString() } + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, ids) -> ids.toTypedArray() } + .toMap(HashMap()) + + if (season != 0) { + result.remove(0) + } + return result + } + + private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { @@ -1439,7 +1369,6 @@ class ResultViewModel2 : ViewModel() { } options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) - options.addAll( listOf( txt(R.string.episode_action_auto_download) to ACTION_DOWNLOAD_EPISODE, @@ -1461,9 +1390,14 @@ class ResultViewModel2 : ViewModel() { val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched - options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) - } + val markUpToText = + if (isWatched) R.string.action_remove_mark_watched_up_to_this_episode + else R.string.action_mark_watched_up_to_this_episode + options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) + + options.add(txt(markUpToText) to ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE) + } postPopup( txt( activity?.getNameFull( @@ -1537,16 +1471,17 @@ class ResultViewModel2 : ViewModel() { ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return - downloadEpisode( - activity, - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url, + ).toWrapper() ) } @@ -1557,9 +1492,8 @@ class ResultViewModel2 : ViewModel() { LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> - ioSafe { - startDownload( - activity, + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( click.data, response.isMovie(), response.name, @@ -1570,8 +1504,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ) - } + ).toWrapper() + ) showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -1610,28 +1544,25 @@ class ResultViewModel2 : ViewModel() { } 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) - } - } + val list = HashMap(currentResponse?.syncData ?: emptyMap()) + val generator = generator ?: return + + // I know kinda shit to iterate all, but it is 100% sure to work + val index = generator.videos.indexOfFirst { value -> value.id == click.data.id } + if (currentResponse?.type == TvType.CustomMedia) { - generator?.generateLinks( + generator.generateLinks( + offset = index, clearCache = true, - LOADTYPE_ALL, + isCasting = false, + sourceTypes = LOADTYPE_ALL, callback = {}, subtitleCallback = {}) } else { activity?.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - generator ?: return, list + generator, index,list ) ) } @@ -1640,17 +1571,37 @@ class ResultViewModel2 : ViewModel() { ACTION_MARK_AS_WATCHED -> { val isWatched = getVideoWatchState(click.data.id) == VideoWatchState.Watched - if (isWatched) { setVideoWatchState(click.data.id, VideoWatchState.None) } else { setVideoWatchState(click.data.id, VideoWatchState.Watched) } - // Kinda dirty to reload all episodes :( reloadEpisodes() } + ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE -> ioSafe { + val editor = context?.let { it1 -> editor(it1, false) } + + if (editor != null) { + val (clickSeason, clickEpisode) = click.data.let { + (it.season ?: 0) to it.episode + } + val watchState = + if (getVideoWatchState(click.data.id) == VideoWatchState.Watched) VideoWatchState.None else VideoWatchState.Watched + val seasons = getEpisodesIdsBySeason(clickSeason) + + seasons.keys.forEach { currentSeason -> + var episodeIds = seasons[currentSeason] ?: emptyArray() + if (currentSeason == clickSeason) episodeIds = + episodeIds.sliceArray(0 until clickEpisode) + markEpisodes(editor, episodeIds, watchState) + } + editor.apply() + reloadEpisodes() + } + } + else -> { val action = VideoClickActionHolder.getActionById(click.action) ?: return @@ -1735,14 +1686,13 @@ class ResultViewModel2 : ViewModel() { } val realRecommendations = ArrayList() - val apiNames = synchronized(apis) { - apis.filter { - it.name.contains("gogoanime", true) || - it.name.contains("9anime", true) - }.map { - it.name - } + val apiNames = 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)) @@ -1761,7 +1711,7 @@ class ResultViewModel2 : ViewModel() { { if (this !is AnimeLoadResponse) return@runAllAsync // already exist, no need to run getTracker - if (this.getAniListId() != null && this.getMalId() != null) return@runAllAsync + if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync val res = APIHolder.getTracker( listOfNotNull( @@ -1779,9 +1729,12 @@ class ResultViewModel2 : ViewModel() { this.year ) + val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name) + val ids = arrayOf( AccountManager.malApi.idPrefix to res?.malId?.toString(), - AccountManager.aniListApi.idPrefix to res?.aniId + AccountManager.aniListApi.idPrefix to res?.aniId, + AccountManager.kitsuApi.idPrefix to kitsuId ) if (ids.any { (id, new) -> @@ -1803,6 +1756,7 @@ class ResultViewModel2 : ViewModel() { // set posters, might fuck up due to headers idk posterUrl = posterUrl ?: res?.image backgroundPosterUrl = backgroundPosterUrl ?: res?.cover + logoUrl = logoUrl }, { if (meta == null) return@runAllAsync @@ -1877,11 +1831,10 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(name: String) { - fillers = - ioWorkSafe { - FillerEpisodeCheck.getFillerEpisodes(name) - } ?: emptyMap() + private suspend fun updateFillers(data: LoadResponse) { + fillers = ioWorkSafe { + FillerEpisodeCheck.getFillerEpisodes(data) + } ?: hashSetOf() } fun changeDubStatus(status: DubStatus) { @@ -1943,7 +1896,10 @@ class ResultViewModel2 : ViewModel() { return when (sorting) { EpisodeSortType.NUMBER_ASC -> episodes.sortedBy { it.episode } EpisodeSortType.NUMBER_DESC -> episodes.sortedByDescending { it.episode } - EpisodeSortType.RATING_HIGH_LOW -> episodes.sortedByDescending { it.score?.toDouble() ?: 0.0 } + EpisodeSortType.RATING_HIGH_LOW -> episodes.sortedByDescending { + it.score?.toDouble() ?: 0.0 + } + EpisodeSortType.RATING_LOW_HIGH -> episodes.sortedBy { it.score?.toDouble() ?: 0.0 } EpisodeSortType.DATE_NEWEST -> episodes.sortedByDescending { it.airDate } EpisodeSortType.DATE_OLDEST -> episodes.sortedBy { it.airDate } @@ -1962,6 +1918,7 @@ class ResultViewModel2 : ViewModel() { val text = txt( when (response.type) { TvType.Torrent -> R.string.play_torrent_button + TvType.TvSeries -> R.string.play_full_series_button else -> { if (response.type.isLiveStream()) R.string.play_livestream_button @@ -2077,29 +2034,8 @@ class ResultViewModel2 : ViewModel() { ) _selectedSeason.postValue( - 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 - ) - } - } - } - + (currentResponse as? EpisodeResponse)?.seasonNames.getSeasonTxt(indexer.season) ) _selectedRangeIndex.postValue( @@ -2235,8 +2171,8 @@ class ResultViewModel2 : ViewModel() { ) { _episodes.postValue(Resource.Loading()) - if (updateFillers && loadResponse is AnimeLoadResponse) { - updateFillers(loadResponse.name) + if (updateFillers) { + updateFillers(loadResponse) } val allEpisodes = when (loadResponse) { @@ -2269,7 +2205,7 @@ class ResultViewModel2 : ViewModel() { filterName(i.name), i.posterUrl, episode, - seasonData?.season ?: i.season, + i.season, if (seasonData != null) seasonData.displaySeason else i.season, i.data, loadResponse.apiName, @@ -2277,12 +2213,13 @@ class ResultViewModel2 : ViewModel() { index, i.score, i.description, - fillers.getOrDefault(episode, false), + fillers.contains(episode), loadResponse.type, mainId, totalIndex, airDate = i.date, runTime = i.runTime, + seasonData = seasonData, ) val season = eps.seasonIndex ?: 0 @@ -2325,7 +2262,7 @@ class ResultViewModel2 : ViewModel() { filterName(episode.name), episode.posterUrl, episodeIndex, - seasonData?.season ?: episode.season, + episode.season, if (seasonData != null) seasonData.displaySeason else episode.season, episode.data, loadResponse.apiName, @@ -2339,6 +2276,7 @@ class ResultViewModel2 : ViewModel() { totalIndex, airDate = episode.date, runTime = episode.runTime, + seasonData = seasonData, ) val season = ep.seasonIndex ?: 0 @@ -2437,21 +2375,7 @@ class ResultViewModel2 : ViewModel() { _dubSubSelections.postValue(dubSelection.map { txt(it) to it }) if (loadResponse is EpisodeResponse) { _seasonSelections.postValue(seasonsSelection.map { seasonNumber -> - val seasonData = loadResponse.seasonNames.getSeason(seasonNumber) - val fixedSeasonNumber = seasonData?.displaySeason ?: seasonNumber - val suffix = seasonData?.name?.let { " $it" } ?: "" - // If displaySeason is null then only show the name! - val name = if (seasonData?.name != null && seasonData.displaySeason == null) { - txt(seasonData.name) - } else { - txt( - R.string.season_format, - txt(R.string.season), - fixedSeasonNumber, - suffix - ) - } - name to seasonNumber + loadResponse.seasonNames.getSeasonTxt(seasonNumber) to seasonNumber }) } @@ -2529,25 +2453,34 @@ class ResultViewModel2 : ViewModel() { loadResponse.trailers.windowed(limit, limit, true).takeWhile { list -> list.amap { trailerData -> try { - val links = arrayListOf() + val links = arrayListOf>() val subs = arrayListOf() if (!loadExtractor( trailerData.extractorUrl, trailerData.referer, { subs.add(it) }, - { links.add(it) }) && trailerData.raw + { + links.add( + Pair( + it, + trailerData.extractorUrl + ) + ) + }) && trailerData.raw ) { arrayListOf( - newExtractorLink( - "", - "Trailer", - trailerData.extractorUrl, - type = INFER_TYPE - ) { - this.referer = trailerData.referer ?: "" - this.quality = Qualities.Unknown.value - this.headers = trailerData.headers - } + Pair( + newExtractorLink( + "", + "Trailer", + trailerData.extractorUrl, + type = INFER_TYPE + ) { + this.referer = trailerData.referer ?: "" + this.quality = Qualities.Unknown.value + this.headers = trailerData.headers + }, trailerData.extractorUrl + ) ) to arrayListOf() } else { links to subs @@ -2631,6 +2564,7 @@ class ResultViewModel2 : ViewModel() { override var syncData: MutableMap = mutableMapOf(), override var posterHeaders: Map? = null, override var backgroundPosterUrl: String? = null, + override var logoUrl: String? = null, override var contentRating: String? = null, override var uniqueUrl: String = url, val id: Int?, @@ -2744,7 +2678,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - VideoDownloadHelper.DownloadHeaderCached( + DownloadObjects.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, @@ -2772,4 +2706,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} 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 ad5d89d18..4231819dd 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 @@ -2,10 +2,11 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater 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.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UiText @@ -13,93 +14,56 @@ import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair -class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter() { - private val selection: MutableList = mutableListOf() +class SelectAdaptor(val callback: (Any) -> Unit) : + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.second == b.second + }, contentSame = { a, b -> + a == b + })) { private var selectedIndex: Int = -1 - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return SelectViewHolder( - ResultSelectionBinding.inflate(LayoutInflater.from(parent.context), parent, false), - - //LayoutInflater.from(parent.context).inflate(R.layout.result_selection, parent, false), + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ResultSelectionBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SelectViewHolder -> { - holder.bind(selection[position], position == selectedIndex, callback) + override fun onBindContent(holder: ViewHolderState, item: SelectData, position: Int) { + when (val binding = holder.view) { + is ResultSelectionBinding -> { + binding.root.apply { + if (isLayout(TV)) { + isFocusable = true + isFocusableInTouchMode = true + } + + isSelected = position == selectedIndex + setText(item.first) + setOnClickListener { + callback.invoke(item.second) + } + } } } } - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if(holder.itemView.hasFocus()) { + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } } - override fun getItemCount(): Int { - return selection.size - } - fun select(newIndex: Int, recyclerView: RecyclerView?) { - if(recyclerView == null) return - if(newIndex == selectedIndex) return + if (recyclerView == null) return + if (newIndex == selectedIndex) return val oldIndex = selectedIndex selectedIndex = newIndex notifyItemChanged(selectedIndex) notifyItemChanged(oldIndex) } - - fun updateSelectionList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SelectDataCallback(this.selection, newList) - ) - - selection.clear() - selection.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - - private class SelectViewHolder( - binding: ResultSelectionBinding, - ) : - RecyclerView.ViewHolder(binding.root) { - private val item: MaterialButton = binding.root - - fun bind( - data: SelectData, isSelected: Boolean, callback: (Any) -> Unit - ) { - if (isLayout(TV)) { - item.isFocusable = true - item.isFocusableInTouchMode = true - } - - item.isSelected = isSelected - item.setText(data.first) - item.setOnClickListener { - callback.invoke(data.second) - } - } - } } - -class SelectDataCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].second == newList[newItemPosition].second - - 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/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 35680b060..6c5c64ff8 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 @@ -11,6 +11,7 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.throwAbleToResource import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncAPI @@ -276,6 +277,7 @@ class SyncViewModel : ViewModel() { // fix because of bad old data :pensive: val realName = when (syncName) { "MAL" -> malApi.idPrefix + "Kitsu" -> kitsuApi.idPrefix "Simkl" -> simklApi.idPrefix "AniList" -> aniListApi.idPrefix else -> syncName 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 f318401c0..7b63b6ede 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,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout -import com.lagradost.cloudstream3.utils.UIHelper.toPx import kotlin.math.roundToInt /** Click */ @@ -31,13 +31,28 @@ class SearchClickCallback( ) class SearchAdapter( - private val cardList: MutableList, private val resView: AutofitRecyclerView, + private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, -) : RecyclerView.Adapter() { +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name + } +})) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } + var hasNext: Boolean = false - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + private val coverRatio = if(isHorizontal) 1.8 else 0.68 + + private val coverHeight: Int get() = (resView.itemWidth / coverRatio).roundToInt() + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val layout = @@ -49,84 +64,36 @@ class SearchAdapter( inflater, parent, false - ) //R.layout.search_result_grid_expanded else R.layout.search_result_grid - - - - return CardViewHolder( - layout, - clickCallback, - resView - ) + ) + return ViewHolderState(layout) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position], position) + override fun onClearView(holder: ViewHolderState) { + clearImage( + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> binding.imageView + is SearchResultGridBinding -> binding.imageView + else -> null } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SearchResponseDiffCallback(this.cardList, newList) ) - - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) } - class CardViewHolder( - val binding: ViewBinding, - private val clickCallback: (SearchClickCallback) -> Unit, - resView: AutofitRecyclerView - ) : - RecyclerView.ViewHolder(binding.root) { - - private val compactView = false//itemView.context.getGridIsCompact() - private val coverHeight: Int = - if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() - - private val cardView = when(binding) { + override fun onBindContent(holder: ViewHolderState, item: SearchResponse, position: Int) { + val imageView = when (val binding = holder.view) { is SearchResultGridExpandedBinding -> binding.imageView is SearchResultGridBinding -> binding.imageView else -> null } - fun bind(card: SearchResponse, position: Int) { - if (!compactView) { - cardView?.apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - } + if (imageView != null) { + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + if (imageView.layoutParams.width != params.width || imageView.layoutParams.height != params.height) { + imageView.layoutParams = params } - - SearchResultBuilder.bind(clickCallback, card, position, itemView) } + SearchResultBuilder.bind(clickCallback, item, position, holder.view.root) } -} - -class SearchResponseDiffCallback( - 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] } \ No newline at end of file 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 1922e4fae..5f5b064b5 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,11 +3,9 @@ package com.lagradost.cloudstream3.ui.search import android.app.Activity import android.content.Intent import android.content.DialogInterface -import android.content.res.Configuration import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import android.os.Bundle -import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -20,20 +18,20 @@ import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.LinearLayoutManager import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.doOnLayout 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.APIHolder.getApiFromNameNull -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.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.MainAPI @@ -49,16 +47,21 @@ 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.BaseFragment 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.HomeViewModel 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.setRecycledViewPool 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.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality @@ -67,18 +70,23 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getApiSettings import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe 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.SubtitleHelper +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import java.util.Locale import java.util.concurrent.locks.ReentrantLock -class SearchFragment : Fragment() { +class SearchFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentSearchBinding::bind) +) { companion object { fun List.filterSearchResponse(): List { return this.filter { response -> @@ -97,14 +105,13 @@ class SearchFragment : Fragment() { fun newInstance(query: String): Bundle { return Bundle().apply { - if(query.isNotBlank()) putString(SEARCH_QUERY, query) + if (query.isNotBlank()) putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() private var bottomSheetDialog: BottomSheetDialog? = null - var binding: FragmentSearchBinding? = null private val speechRecognizerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -118,6 +125,9 @@ class SearchFragment : Fragment() { } } + override fun pickLayout(): Int? = + if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -127,37 +137,13 @@ 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 - } - - private fun fixGrid() { - activity?.getSpanCount()?.let { - currentSpan = it - } - binding?.searchAutofitResults?.spanCount = currentSpan - currentSpan = currentSpan - HomeFragment.configEvent.invoke(currentSpan) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - fixGrid() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() - binding = null + activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } @@ -182,7 +168,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() + (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*, *>)?.clearState() context?.let { ctx -> val default = enumValues().sorted().filter { it != TvType.NSFW } .map { it.ordinal.toString() }.toSet() @@ -231,42 +217,59 @@ class SearchFragment : Fragment() { } } + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + // Fix grid + currentSpan = view.context.getSpanCount() + binding?.searchAutofitResults?.spanCount = currentSpan + HomeFragment.configEvent.invoke() + } - fixPaddingStatusbar(binding?.searchRoot) - fixGrid() + override fun onBindingCreated( + binding: FragmentSearchBinding, + savedInstanceState: Bundle? + ) { reloadRepos() - - binding?.apply { - val adapter: RecyclerView.Adapter = + binding.apply { + val adapter = SearchAdapter( - ArrayList(), searchAutofitResults, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } - searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = "tv_no_focus_tag" + searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = + "tv_no_focus_tag" + searchAutofitResults.setRecycledViewPool(SearchAdapter.sharedPool) searchAutofitResults.adapter = adapter searchLoadingBar.alpha = 0f } - binding?.voiceSearch?.setOnClickListener { searchView -> + binding.voiceSearch.setOnClickListener { searchView -> searchView?.context?.let { ctx -> try { if (!SpeechRecognizer.isRecognitionAvailable(ctx)) { showToast(R.string.speech_recognition_unavailable) } else { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM + ) putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) - putExtra(RecognizerIntent.EXTRA_PROMPT, ctx.getString(R.string.begin_speaking)) + putExtra( + RecognizerIntent.EXTRA_PROMPT, + ctx.getString(R.string.begin_speaking) + ) } speechRecognizerLauncher.launch(intent) } - } catch (_ : Throwable) { + } catch (_: Throwable) { // launch may throw showToast(R.string.speech_recognition_unavailable) } @@ -274,21 +277,11 @@ class SearchFragment : Fragment() { } val searchExitIcon = - binding?.mainSearch?.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 - - // 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.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() - binding?.searchFilter?.setOnClickListener { searchView -> + binding.searchFilter.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() @@ -300,11 +293,12 @@ class SearchFragment : Fragment() { builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - val selectMainpageBinding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( - builder.layoutInflater, - null, - false - ) + val selectMainpageBinding: HomeSelectMainpageBinding = + HomeSelectMainpageBinding.inflate( + builder.layoutInflater, + null, + false + ) builder.setContentView(selectMainpageBinding.root) builder.show() builder.let { dialog -> @@ -373,7 +367,10 @@ class SearchFragment : Fragment() { if (selectedSearchTypes.toSet() != list.toSet()) { selectedSearchTypes.clear() selectedSearchTypes.addAll(list) - updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes) + updateChips( + binding.tvtypesChipsScroll.tvtypesChips, + selectedSearchTypes + ) } } @@ -399,8 +396,8 @@ class SearchFragment : Fragment() { selectedApis = currentSelectedApis // run search when dialog is close - if(previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { - search(binding?.mainSearch?.query?.toString()) + if (previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { + search(binding.mainSearch.query.toString()) } } updateList(selectedSearchTypes.toList()) @@ -410,19 +407,31 @@ class SearchFragment : Fragment() { val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true + val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() - if (isLayout(TV)) { - binding?.searchFilter?.isFocusable = true - binding?.searchFilter?.isFocusableInTouchMode = true + if (!isLayout(PHONE)) { + binding.searchFilter.isFocusable = true + binding.searchFilter.isFocusableInTouchMode = true } - binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + // Hide suggestions when search view loses focus (phone only) + if (isLayout(PHONE)) { + binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + searchViewModel.clearSuggestions() + } + } + } + + + binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) + searchViewModel.clearSuggestions() - binding?.mainSearch?.let { + binding.mainSearch.let { hideKeyboard(it) } @@ -435,76 +444,49 @@ class SearchFragment : Fragment() { if (showHistory) { searchViewModel.clearSearch() searchViewModel.updateHistory() + searchViewModel.clearSuggestions() + } else { + // Fetch suggestions when user is typing (if enabled) + if (isSearchSuggestionsEnabled) { + searchViewModel.fetchSuggestions(newText) + } } - binding?.apply { - searchHistoryHolder.isVisible = showHistory + binding.apply { + searchHistoryRecycler.isVisible = showHistory searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch + // Hide suggestions when showing history or showing search results + searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled } return true } }) - binding?.searchClearCallHistory?.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") - searchViewModel.updateHistory() - } - DialogInterface.BUTTON_NEGATIVE -> { - } - } - } - - try { - builder.setTitle(R.string.clear_history).setMessage( - ctx.getString(R.string.delete_message).format( - ctx.getString(R.string.history) - ) - ) - .setPositiveButton(R.string.sort_clear, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - // ye you somehow fucked up formatting did you? - } - } - - - } - - observe(searchViewModel.currentHistory) { list -> - binding?.searchClearCallHistory?.isVisible = list.isNotEmpty() - (binding?.searchHistoryRecycler?.adapter as? SearchHistoryAdaptor?)?.updateList(list) - } - - searchViewModel.updateHistory() - observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - if (data.isNotEmpty()) { - (binding?.searchAutofitResults?.adapter as? SearchAdapter)?.updateList(data) + val list = data.list + if (list.isNotEmpty()) { + (binding.searchAutofitResults.adapter as? SearchAdapter)?.submitList( + list + ) } } searchExitIcon?.alpha = 1f - binding?.searchLoadingBar?.alpha = 0f + binding.searchLoadingBar.alpha = 0f } + is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding?.searchLoadingBar?.alpha = 0f + binding.searchLoadingBar.alpha = 0f } + is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding?.searchLoadingBar?.alpha = 1f + binding.searchLoadingBar.alpha = 1f } } } @@ -514,20 +496,33 @@ class SearchFragment : Fragment() { try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding?.searchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { - val newItems = list.map { ongoing -> - val dataList = - if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() + + val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() + + val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> + val index = pinnedOrder.indexOf(providerName) + if (index == -1) Int.MAX_VALUE else index + }) + + (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { + val newItems = sortedList.map { (providerName, providerData) -> + val dataList = providerData.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList - val ongoingList = HomePageList( - ongoing.apiName, + + val homePageList = HomePageList( + providerName, dataListFiltered ) - ongoingList - } - updateList(newItems) + HomeViewModel.ExpandableHomepageList( + homePageList, + providerData.currentPage, + providerData.hasNext + ) + } + + submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { @@ -547,52 +542,123 @@ class SearchFragment : Fragment() { //main_search.onActionViewExpanded()*/ val masterAdapter = - ParentItemAdapter(fragment = this, id = "masterAdapter".hashCode(), { callback -> + ParentItemAdapter(id = "masterAdapter".hashCode(), { callback -> SearchHelper.handleSearchClickCallback(callback) }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null - }) + }, expandCallback = { name -> searchViewModel.expandAndReturn(name) }) + }, expandCallback = { name -> + ioSafe { + searchViewModel.expandAndReturn(name) + } }) - val historyAdapter = SearchHistoryAdaptor(mutableListOf()) { click -> + val historyAdapter = SearchHistoryAdaptor { click -> val searchItem = click.item when (click.clickAction) { SEARCH_HISTORY_OPEN -> { + if (searchItem == null) return@SearchHistoryAdaptor searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) - updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, searchItem.type.toMutableList()) - binding?.mainSearch?.setQuery(searchItem.searchText, true) + updateChips( + binding.tvtypesChipsScroll.tvtypesChips, + searchItem.type.toMutableList() + ) + binding.mainSearch.setQuery(searchItem.searchText, true) } + SEARCH_HISTORY_REMOVE -> { + if (searchItem == null) return@SearchHistoryAdaptor removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } + + SEARCH_HISTORY_CLEAR -> { + // Show confirmation dialog (from footer button) + 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") + searchViewModel.updateHistory() + } + + DialogInterface.BUTTON_NEGATIVE -> { + } + } + } + + try { + builder.setTitle(R.string.clear_history).setMessage( + ctx.getString(R.string.delete_message).format( + ctx.getString(R.string.history) + ) + ) + .setPositiveButton(R.string.sort_clear, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + } + } + } + else -> { // wth are you doing??? } } } - binding?.apply { + val suggestionAdapter = SearchSuggestionAdapter { callback -> + when (callback.clickAction) { + SEARCH_SUGGESTION_CLICK -> { + // Search directly + binding.mainSearch.setQuery(callback.suggestion, true) + searchViewModel.clearSuggestions() + } + SEARCH_SUGGESTION_FILL -> { + // Fill the search box without searching + binding.mainSearch.setQuery(callback.suggestion, false) + } + SEARCH_SUGGESTION_CLEAR -> { + // Clear suggestions (from footer button) + searchViewModel.clearSuggestions() + } + } + } + + binding.apply { searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) + // Setup suggestions RecyclerView + searchSuggestionsRecycler.adapter = suggestionAdapter + searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context) + + searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.adapter = masterAdapter //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) 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()) { + 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) + + // Queries are dropped if you are submitted before layout finishes + mainSearch.doOnLayout { + 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) @@ -600,18 +666,37 @@ class SearchFragment : Fragment() { } } + observe(searchViewModel.currentHistory) { list -> + (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) + // Scroll to top to show newest items (list is sorted by newest first) + if (list.isNotEmpty()) { + binding.searchHistoryRecycler.scrollToPosition(0) + } + } - // SubtitlesFragment.push(activity) - //searchViewModel.search("iron man") - //(activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") -/* - (activity as AppCompatActivity?)?.supportFragmentManager.beginTransaction() - .setCustomAnimations(R.anim.enter_anim, - R.anim.exit_anim, - R.anim.pop_enter, - R.anim.pop_exit) - .add(R.id.homeRoot, PlayerFragment.newInstance(PlayerData(0, null,0))) - .commit()*/ + // Observe search suggestions + observe(searchViewModel.searchSuggestions) { suggestions -> + val hasSuggestions = suggestions.isNotEmpty() + binding.searchSuggestionsRecycler.isVisible = hasSuggestions + (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) + + // On non-phone layouts, redirect focus and handle back button + if (!isLayout(PHONE)) { + if (hasSuggestions) { + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler + // Attach back button callback to clear suggestions + activity?.attachBackPressedCallback("SearchFragment") { + searchViewModel.clearSuggestions() + } + } else { + // Reset to default focus target (history) + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler + // Detach back button callback when no suggestions + activity?.detachBackPressedCallback("SearchFragment") + } + } + } + + searchViewModel.updateHistory() } - } 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 e176d6c9b..449a04bf8 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 @@ -11,7 +11,7 @@ import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -31,7 +31,7 @@ object SearchHelper { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = card.name, poster = card.posterUrl, episode = card.episode ?: 0, 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 4ef5fa698..4868abb3d 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 @@ -2,11 +2,17 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.core.view.isGone import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +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 data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -16,84 +22,73 @@ data class SearchHistoryItem( ) data class SearchHistoryCallback( - val item: SearchHistoryItem, + val item: SearchHistoryItem?, val clickAction: Int, ) const val SEARCH_HISTORY_OPEN = 0 const val SEARCH_HISTORY_REMOVE = 1 +const val SEARCH_HISTORY_CLEAR = 2 class SearchHistoryAdaptor( - private val cardList: MutableList, private val clickCallback: (SearchHistoryCallback) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a,b -> + a.searchedAt == b.searchedAt && a.searchText == b.searchText +})) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - clickCallback, ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position]) + override fun onBindContent( + holder: ViewHolderState, + item: SearchHistoryItem, + position: Int + ) { + val binding = holder.view as? SearchHistoryItemBinding ?: return + binding.apply { + homeHistoryTitle.text = item.searchText + + homeHistoryRemove.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_REMOVE)) + } + homeHistoryTab.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_OPEN)) } } } - - override fun getItemCount(): Int { - return cardList.size - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SearchHistoryDiffCallback(this.cardList, newList) + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) } - - class CardViewHolder( - val binding: SearchHistoryItemBinding, - 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 - - fun bind(card: SearchHistoryItem) { - binding.apply { - homeHistoryTitle.text = card.searchText - - homeHistoryRemove.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) - } - homeHistoryTab.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) - } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchHistoryFooterBinding ?: return + // Hide footer when list is empty + binding.searchClearCallHistory.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(null, SEARCH_HISTORY_CLEAR)) } } } } - -class SearchHistoryDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].searchText == newList[newItemPosition].searchText - - 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/search/SearchResultBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt index 56f726fc1..fd99b8d4b 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 @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.search import android.annotation.SuppressLint import android.content.Context +import android.content.res.ColorStateList import android.view.View import android.widget.ImageView import android.widget.ProgressBar @@ -21,10 +22,12 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.getImageFromDrawable object SearchResultBuilder { @@ -64,6 +67,7 @@ object SearchResultBuilder { val bar: ProgressBar? = itemView.findViewById(R.id.watchProgress) val playImg: ImageView? = itemView.findViewById(R.id.search_item_download_play) + val episodeText: TextView? = itemView.findViewById(R.id.episode_text) // Do logic @@ -73,10 +77,12 @@ object SearchResultBuilder { textIsSub?.isVisible = false textFlag?.isVisible = false rating?.isVisible = false + episodeText?.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 showEpisodeText = showCache[cardText?.context?.getString(R.string.show_episode_text_key)] ?: false val showHd = showCache[textQuality?.context?.getString(R.string.show_hd_key)] ?: false val showRatingView = showCache[textQuality?.context?.getString(R.string.show_rating_key)] ?: false @@ -126,18 +132,11 @@ object SearchResultBuilder { cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true - cardView.loadImage(card.posterUrl, card.posterHeaders) { - error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } - /* - createPaletteAsync is currently disabled as we use hardware acceleration on images - val posterUrl = card.posterUrl - if (posterUrl != null && colorCallback != null) { - this.listener(onSuccess = { _,success -> - val bitmap = success.image.toBitmap() - createPaletteAsync(posterUrl, bitmap, colorCallback) - }) - }*/ - } + if (!card.posterUrl.isNullOrEmpty()) { + cardView.loadImage(card.posterUrl, card.posterHeaders) { + error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } + } + } else cardView.loadImage(R.drawable.default_cover) fun click(view: View?) { clickCallback.invoke( @@ -259,12 +258,12 @@ object SearchResultBuilder { bar?.progress = (pos.position / 1000).toInt() bar?.visibility = View.VISIBLE } - playImg?.visibility = View.VISIBLE - - if (card.type?.isMovieType() == false) { - cardText?.text = - cardText?.context?.getNameFull(card.name, card.episode, card.season) + if (card.type?.isMovieType() == false && showEpisodeText) { + episodeText?.context?.getShortSeasonText(card.episode, card.season)?.let {text-> + episodeText.text = text + episodeText.isVisible = true + } } } @@ -303,5 +302,29 @@ object SearchResultBuilder { } } } + + // This is the logic for making the rounded corners more round on the top and bottom element + // a bit dirty to do memory allocation, but it makes it more extensible and is easier to reason about + // then a large if statement + + // Requires that the ordering here is the same as in the xml + val boxes = arrayListOf() + for (view in arrayOf(textIsDub, textIsSub, rating)) { + if (view?.isVisible == true) { + boxes.add(view) + } + } + if (boxes.size == 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_both) + } else if (boxes.size > 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_top) + for (i in 1 until boxes.size) { + boxes[i].setBackgroundResource(R.drawable.bg_color_center) + } + boxes[boxes.size - 1].setBackgroundResource(R.drawable.bg_color_bottom) + } + textIsDub?.apply { + backgroundTintList = ColorStateList.valueOf(context.colorFromAttribute(R.attr.textColor)) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt new file mode 100644 index 000000000..74d5e7b08 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt @@ -0,0 +1,85 @@ +package com.lagradost.cloudstream3.ui.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding +import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +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 + +const val SEARCH_SUGGESTION_CLICK = 0 +const val SEARCH_SUGGESTION_FILL = 1 +const val SEARCH_SUGGESTION_CLEAR = 2 + +data class SearchSuggestionCallback( + val suggestion: String, + val clickAction: Int, +) + +class SearchSuggestionAdapter( + private val clickCallback: (SearchSuggestionCallback) -> Unit, +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + } + + override fun onBindContent( + holder: ViewHolderState, + item: String, + position: Int + ) { + val binding = holder.view as? SearchSuggestionItemBinding ?: return + binding.apply { + suggestionText.text = item + + // Click on the whole item to search + suggestionItem.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) + } + + // Click on the arrow to fill the search box without searching + suggestionFill.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) + } + } + } + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchSuggestionFooterBinding ?: return + binding.clearSuggestionsButton.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt new file mode 100644 index 000000000..8dbd78178 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -0,0 +1,74 @@ +package com.lagradost.cloudstream3.ui.search + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.nicehttp.NiceResponse + +/** + * API for fetching search suggestions from external sources. + * Uses TheMovieDB API to provide movie/show/anime related suggestions. + */ +object SearchSuggestionApi { + private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" + private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" + + data class TmdbSearchResult( + @JsonProperty("results") val results: List? + ) + + data class TmdbSearchItem( + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("original_title") val originalTitle: String?, + @JsonProperty("original_name") val originalName: String? + ) + + /** + * Fetches search suggestions from TheMovieDB multi search API. + * Returns suggestions for movies, TV series, and anime. + * + * @param query The search query to get suggestions for + * @return List of suggestion strings, empty list on failure + */ + suspend fun getSuggestions(query: String): List { + if (query.isBlank() || query.length < 2) return emptyList() + + return try { + val response = app.get( + TMDB_API_URL, + params = mapOf( + "api_key" to TMDB_API_KEY, + "query" to query, + "language" to "en-US" + ), + cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) + ) + + parseSuggestions(response) + } catch (e: Exception) { + logError(e) + emptyList() + } + } + + /** + * Parses the TMDB search response and extracts movie/TV show titles. + * Filters to only include movies, TV shows, and anime. + */ + private fun parseSuggestions(response: NiceResponse): List { + return try { + val parsed = response.parsed() + parsed.results + ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } + ?.mapNotNull { it.title ?: it.name } + ?.distinct() + ?.take(10) + ?: emptyList() + } catch (e: Exception) { + logError(e) + emptyList() + } + } +} 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 839b9d3f8..f60588e35 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 @@ -5,51 +5,70 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.HomePageList 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.ui.APIRepository +import com.lagradost.cloudstream3.ui.home.HomeViewModel 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.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -data class OnGoingSearch( - val apiName: String, - val data: Resource> + +data class ExpandableSearchList( + var list: List, var currentPage: Int, var hasNext: Boolean, ) const val SEARCH_HISTORY_KEY = "search_history" class SearchViewModel : ViewModel() { - private val _searchResponse: MutableLiveData>> = + private val _searchResponse: MutableLiveData> = MutableLiveData() - val searchResponse: LiveData>> get() = _searchResponse + val searchResponse: LiveData> get() = _searchResponse - private val _currentSearch: MutableLiveData> = MutableLiveData() - val currentSearch: LiveData> get() = _currentSearch + private val _currentSearch: MutableLiveData> = + MutableLiveData() + val currentSearch: LiveData> get() = _currentSearch private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory - private var repos = synchronized(apis) { apis.map { APIRepository(it) } } + private val _searchSuggestions: MutableLiveData> = MutableLiveData() + val searchSuggestions: LiveData> get() = _searchSuggestions + + private var suggestionJob: Job? = null + + private var repos = apis.withLock { apis.map { APIRepository(it) } } fun clearSearch() { - _searchResponse.postValue(Resource.Success(ArrayList())) - _currentSearch.postValue(emptyList()) + _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() } + var lastQuery: String? = null + + /** Save which providers can searched again and which search result page they are on. + * Maps provider name to search list. + * @see [HomeViewModel.expandable] */ + private val expandableSearches: MutableMap = mutableMapOf() + private var currentSearchIndex = 0 private var onGoingSearch: Job? = null fun reloadRepos() { - repos = synchronized(apis) { apis.map { APIRepository(it) } } + repos = apis.withLock { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -63,13 +82,117 @@ class SearchViewModel : ViewModel() { onGoingSearch = search(query, providersActive, ignoreSettings, isQuickSearch) } - fun updateHistory() = viewModelScope.launch { - ioSafe { - val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { - getKey(it) - }?.sortedByDescending { it.searchedAt } ?: emptyList() - _currentHistory.postValue(items) + fun updateHistory() = ioSafe { + val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { + getKey(it) + }?.sortedByDescending { it.searchedAt } ?: emptyList() + _currentHistory.postValue(items) + } + + /** + * Fetches search suggestions with debouncing. + * Waits 300ms before making the API call to avoid too many requests. + * + * @param query The search query to get suggestions for + */ + fun fetchSuggestions(query: String) { + suggestionJob?.cancel() + + if (query.isBlank() || query.length < 2) { + _searchSuggestions.postValue(emptyList()) + return } + + suggestionJob = ioSafe { + delay(300) // Debounce + val suggestions = SearchSuggestionApi.getSuggestions(query) + _searchSuggestions.postValue(suggestions) + } + } + + /** + * Clears the current search suggestions. + */ + fun clearSuggestions() { + suggestionJob?.cancel() + _searchSuggestions.postValue(emptyList()) + } + + private val lock: MutableSet = mutableSetOf() + + // ExpandableHomepageList because the home adapter is reused in the search fragment + suspend fun expandAndReturn(name: String): HomeViewModel.ExpandableHomepageList? { + if (lock.contains(name)) return null + val query = lastQuery ?: return null + val repo = repos.find { it.name == name } ?: return null + + lock += name + + expandableSearches[name]?.let { current -> + debugAssert({ !current.hasNext }) { + "Expand called when not needed" + } + + val nextPage = current.currentPage + 1 + val next = repo.search(query, nextPage) + if (next is Resource.Success) { + val nextValue = next.value + expandableSearches[name]?.apply { + this.hasNext = nextValue.hasNext + this.currentPage = nextPage + + debugWarning({ nextValue.items.any { outer -> this.list.any { it.url == outer.url } } }) { + "Expanded search contained an item that was previously already in the list.\nQuery = $query, ${nextValue.items} = ${this.list}" + } + + // just to be sure we are not adding the same shit for some reason + // Avoids weird behavior in the recyclerview by recreating the list + this.list = (this.list + nextValue.items).distinctBy { it.url } + } ?: debugWarning { + "Expanded an item not in search load named $name, current list is ${expandableSearches.keys}" + } + } else { + current.hasNext = false + } + + _searchResponse.postValue(Resource.Success(bundleSearch(expandableSearches))) + _currentSearch.postValue(expandableSearches) + } + + lock -= name + + val item = expandableSearches[name] ?: return null + return HomeViewModel.ExpandableHomepageList( + HomePageList(name, item.list), + item.currentPage, + item.hasNext + ) + } + + private fun bundleSearch(lists: MutableMap): ExpandableSearchList { + if (lists.size == 1) { + return lists.values.first() + } + + val list = ArrayList() + val nestedList = + lists.map { it.value.list } + + // I do it this way to move the relevant search results to the top + var index = 0 + while (true) { + var added = 0 + for (sublist in nestedList) { + if (sublist.size > index) { + list.add(sublist[index]) + added++ + } + } + if (added == 0) break + index++ + } + + return ExpandableSearchList(list, 1, false) } private fun search( @@ -100,43 +223,30 @@ class SearchViewModel : ViewModel() { } _searchResponse.postValue(Resource.Loading()) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() - - _currentSearch.postValue(ArrayList()) + lastQuery = query withContext(Dispatchers.IO) { // This interrupts UI otherwise - val currentList = ArrayList() - repos.filter { a -> (ignoreSettings || (providersActive.isEmpty() || providersActive.contains(a.name))) && (!isQuickSearch || a.hasQuickSearch) }.amap { a -> // Parallel - val search = if (isQuickSearch) a.quickSearch(query) else a.search(query) + val search = if (isQuickSearch) a.quickSearch(query) else a.search(query, 1) if (currentSearchIndex != currentIndex) return@amap - currentList.add(OnGoingSearch(a.name, search)) - _currentSearch.postValue(currentList) + if (search is Resource.Success) { + val searchValue = search.value + expandableSearches[a.name] = + ExpandableSearchList(searchValue.items, 1, searchValue.hasNext) + } + + _currentSearch.postValue(expandableSearches) } if (currentSearchIndex != currentIndex) return@withContext // this should prevent rewrite of existing data bug - _currentSearch.postValue(currentList) - val list = ArrayList() - val nestedList = - currentList.map { it.data } - .filterIsInstance>>().map { it.value } - - // I do it this way to move the relevant search results to the top - var index = 0 - while (true) { - var added = 0 - for (sublist in nestedList) { - if (sublist.size > index) { - list.add(sublist[index]) - added++ - } - } - if (added == 0) break - index++ - } + _currentSearch.postValue(expandableSearches) + val list = bundleSearch(expandableSearches) _searchResponse.postValue(Resource.Success(list)) } 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 e68dcc513..be8b4180c 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,75 +1,56 @@ package com.lagradost.cloudstream3.ui.settings -import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup 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.AuthData +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class AccountClickCallback(val action: Int, val view: View, val card: AuthData) class AccountAdapter( - private val cardList: Array, private val clickCallback: (AccountClickCallback) -> Unit ) : - RecyclerView.Adapter() { + NoStateAdapter( + diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.user.id == b.user.id + }) + ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( AccountSingleBinding.inflate( LayoutInflater.from(parent.context), parent, false - ), //LayoutInflater.from(parent.context).inflate(layout, parent, false), - - clickCallback + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position], position) - } - } + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? AccountSingleBinding ?: return + clearImage(binding.accountProfilePicture) } - override fun getItemCount(): Int { - return cardList.size - } + override fun onBindContent(holder: ViewHolderState, item: AuthData, position: Int) { + val binding = holder.view as? AccountSingleBinding ?: return + binding.apply { + accountName.text = item.user.name + ?: "${binding.accountName.context.getString(R.string.account)} ${position + 1}" + accountProfilePicture.isVisible = true + accountProfilePicture.loadImage( + item.user.profilePicture, + headers = item.user.profilePictureHeaders + ) - override fun getItemId(position: Int): Long { - return cardList[position].user.id.toLong() - } - - class CardViewHolder( - val binding: AccountSingleBinding?, - private val clickCallback: (AccountClickCallback) -> Unit - ) : - RecyclerView.ViewHolder(binding?.root!!) { - - @SuppressLint("StringFormatInvalid") - fun bind(card: AuthData, position: Int) { - // just in case name is null account index will show, should never happened - binding?.apply { - accountName.text = card.user.name ?: "%s %d".format( - binding.accountName.context.getString(R.string.account), - position + 1 - ) - accountProfilePicture.isVisible = true - accountProfilePicture.loadImage( - card.user.profilePicture, - headers = card.user.profilePictureHeaders - ) - - itemView.setOnClickListener { - clickCallback.invoke(AccountClickCallback(0, itemView, card)) - } + root.setOnClickListener { + clickCallback.invoke(AccountClickCallback(0, root, item)) } } } 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 index aa513d87a..93e469a4d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui.settings import android.app.UiModeManager import android.content.Context import android.content.res.Configuration +import android.content.res.Resources import android.os.Build import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R @@ -44,6 +45,11 @@ object Globals { layoutId = layoutIntCorrected() } + /** Returns true if the current orientation is landscape. */ + fun isLandscape(): Boolean = + isLayout(TV or EMULATOR) || + Resources.getSystem().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + /** 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. diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt index 7fcfefb7b..365990646 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt @@ -2,25 +2,30 @@ package com.lagradost.cloudstream3.ui.settings import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ItemLogcatBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState -class LogcatAdapter( - private val logs: List -) : RecyclerView.Adapter() { - - inner class LogViewHolder( - val binding: ItemLogcatBinding - ) : RecyclerView.ViewHolder(binding.root) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { - val binding = ItemLogcatBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return LogViewHolder(binding) +class LogcatAdapter() : NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = String::equals, + contentSame = String::equals + ) +) { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ItemLogcatBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) } - override fun onBindViewHolder(holder: LogViewHolder, position: Int) { - holder.binding.logText.text = logs[position] + override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { + (holder.view as? ItemLogcatBinding)?.apply { + logText.text = item + } } - - override fun getItemCount(): Int = logs.count() } \ No newline at end of file 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 f216219de..8d96a6b14 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 @@ -14,11 +14,10 @@ import androidx.core.content.edit 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.SwitchPreference import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException @@ -29,15 +28,19 @@ import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi 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.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthRepo import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo import com.lagradost.cloudstream3.syncproviders.SubtitleRepo import com.lagradost.cloudstream3.syncproviders.SyncRepo +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.PHONE @@ -66,7 +69,7 @@ import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import qrcode.QRCode -class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { +class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ @SuppressLint("StringFormatInvalid") @@ -136,9 +139,11 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { dialog?.dismissSafe(activity) } - val adapter = AccountAdapter(accounts) { + val adapter = AccountAdapter { dialog?.dismissSafe(activity) api.accountId = it.card.user.id + }.apply { + submitList(accounts.toList()) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter @@ -460,10 +465,12 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { val syncApis = listOf( R.string.mal_key to SyncRepo(malApi), + R.string.kitsu_key to SyncRepo(kitsuApi), R.string.anilist_key to SyncRepo(aniListApi), R.string.simkl_key to SyncRepo(simklApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), R.string.subdl_key to SubtitleRepo(subDlApi), + R.string.animeskip_key to PlainAuthRepo(animeSkipApi), ) for ((key, api) in syncApis) { 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 1e4689bf6..e41109b59 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 @@ -2,9 +2,7 @@ package com.lagradost.cloudstream3.ui.settings 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 @@ -18,17 +16,21 @@ import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AuthRepo +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.errorProfilePic 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.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable @@ -40,7 +42,9 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class SettingsFragment : Fragment() { +class SettingsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(MainSettingsBinding::inflate) +) { companion object { fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null @@ -122,7 +126,6 @@ class SettingsFragment : Fragment() { } } } - UIHelper.fixPaddingStatusbar(settingsToolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { @@ -135,11 +138,20 @@ class SettingsFragment : Fragment() { 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() + safe { activity?.onBackPressedDispatcher?.onBackPressed() } } } } - UIHelper.fixPaddingStatusbar(settingsToolbar) + } + + fun Fragment.setSystemBarsPadding() { + view?.let { + fixSystemBarsPadding( + it, + padLeft = isLayout(TV or EMULATOR), + padBottom = isLandscape() + ) + } } fun getFolderSize(dir: File): Long { @@ -157,24 +169,15 @@ class SettingsFragment : Fragment() { } } - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - 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 - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: MainSettingsBinding) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } @@ -188,13 +191,13 @@ class SettingsFragment : Fragment() { val login = syncApi.authUser() val pic = login?.profilePicture ?: continue - binding?.settingsProfilePic?.let { imageView -> + binding.settingsProfilePic.let { imageView -> imageView.loadImage(pic) { // Fallback to random error drawable error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } } } - binding?.settingsProfileText?.text = login.name + binding.settingsProfileText.text = login.name return true // sync profile exists } return false // not syncing @@ -213,11 +216,11 @@ class SettingsFragment : Fragment() { null } - binding?.settingsProfilePic?.loadImage(currentAccount?.image) - binding?.settingsProfileText?.text = currentAccount?.name + binding.settingsProfilePic.loadImage(currentAccount?.image) + binding.settingsProfileText.text = currentAccount?.name } - binding?.apply { + 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, @@ -244,16 +247,18 @@ class SettingsFragment : Fragment() { } } - val appVersion = getString(R.string.app_version) - val commitInfo = getString(R.string.commit_hash) + val appVersion = BuildConfig.VERSION_NAME + val commitHash = activity?.currentCommitHash() ?: "" 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") + binding.appVersion.text = appVersion + binding.buildDate.text = buildTimestamp + binding.commitHash.text = commitHash + binding.appVersionInfo.setOnLongClickListener { + clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $buildTimestamp") true } } 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 e82481ffa..57f5aa870 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 @@ -2,18 +2,19 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Context import android.net.Uri -import android.os.Build import android.os.Bundle import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit +import androidx.core.os.ConfigurationCompat +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty 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.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity @@ -24,6 +25,7 @@ import com.lagradost.cloudstream3.databinding.AddSiteInputBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.beneneCount @@ -43,90 +45,99 @@ 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.USER_PROVIDER_API -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import java.util.Locale // Change local language settings in the app. fun getCurrentLocale(context: Context): String { - val res = context.resources - 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" - } + val conf = context.resources.configuration + return ConfigurationCompat.getLocales(conf).get(0)?.toLanguageTag() ?: "en" } -// idk, if you find a way of automating this it would be great -// https://www.iemoji.com/view/emoji/1794/flags/antarctica -// Emoji Character Encoding Data --> C/C++/Java Src -// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto +/** + * List of app supported languages. + * Language code shall be a IETF BCP 47 conformant tag + * + * See locales on: + * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json + * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml + * https://iso639-3.sil.org/code_tables/639/data/all +*/ val appLanguages = arrayListOf( /* begin language list */ - Triple("", "Afrikaans", "af"), - Triple("", "عربي شامي", "ajp"), - Triple("", "አማርኛ", "am"), - Triple("", "العربية", "ar"), - Triple("", "اللهجة النجدية", "ars"), - Triple("", "অসমীয়া", "as"), - Triple("", "azərbaycan dili", "az"), - Triple("", "български", "bg"), - Triple("", "বাংলা", "bn"), - Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"), - Triple("", "čeština", "cs"), - Triple("", "Deutsch", "de"), - Triple("", "Ελληνικά", "el"), - Triple("", "English", "en"), - Triple("", "Esperanto", "eo"), - Triple("", "español", "es"), - Triple("", "فارسی", "fa"), - Triple("", "fil", "fil"), - Triple("", "français", "fr"), - Triple("", "galego", "gl"), - Triple("", "हिन्दी", "hi"), - Triple("", "hrvatski", "hr"), - Triple("", "magyar", "hu"), - Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), - Triple("", "italiano", "it"), - Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"), - Triple("", "日本語 (にほんご)", "ja"), - Triple("", "ಕನ್ನಡ", "kn"), - Triple("", "한국어", "ko"), - Triple("", "lietuvių kalba", "lt"), - Triple("", "latviešu valoda", "lv"), - Triple("", "македонски", "mk"), - Triple("", "മലയാളം", "ml"), - Triple("", "bahasa Melayu", "ms"), - Triple("", "Malti", "mt"), - Triple("", "ဗမာစာ", "my"), - Triple("", "नेपाली", "ne"), - Triple("", "Nederlands", "nl"), - Triple("", "norsk nynorsk", "nn"), - Triple("", "norsk bokmål", "no"), - Triple("", "ଓଡ଼ିଆ", "or"), - Triple("", "polski", "pl"), - Triple("\uD83C\uDDF5\uD83C\uDDF9", "português", "pt"), - Triple("\uD83E\uDD8D", "mmmm... monke", "qt"), - Triple("", "română", "ro"), - Triple("", "русский", "ru"), - Triple("", "slovenčina", "sk"), - Triple("", "Soomaaliga", "so"), - Triple("", "svenska", "sv"), - Triple("", "தமிழ்", "ta"), - Triple("", "ትግርኛ", "ti"), - Triple("", "Tagalog", "tl"), - Triple("", "Türkçe", "tr"), - Triple("", "українська", "uk"), - Triple("", "اردو", "ur"), - Triple("", "Tiếng Việt", "vi"), - Triple("", "中文", "zh"), - Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), + Pair("Afrikaans", "af"), + Pair("Azərbaycan dili", "az"), + Pair("Bahasa Indonesia", "in"), + Pair("Bahasa Melayu", "ms"), + Pair("Deutsch", "de"), + Pair("English", "en"), + Pair("Español", "es"), + Pair("Esperanto", "eo"), + Pair("Français", "fr"), + Pair("Galego", "gl"), + Pair("hrvatski", "hr"), + Pair("Italiano", "it"), + Pair("Latviešu valoda", "lv"), + Pair("Lietuvių kalba", "lt"), + Pair("Magyar", "hu"), + Pair("Malti", "mt"), + Pair("mmmm... monke", "qt"), + Pair("Nederlands", "nl"), + Pair("Norsk bokmål", "no"), + Pair("Norsk nynorsk", "nn"), + Pair("Polski", "pl"), + Pair("Português", "pt"), + Pair("Português (Brasil)", "pt-BR"), + Pair("Română", "ro"), + Pair("Slovenčina", "sk"), + Pair("Soomaaliga", "so"), + Pair("Svenska", "sv"), + Pair("Tagalog", "tl"), + Pair("Tiếng Việt", "vi"), + Pair("Türkçe", "tr"), + Pair("Wikang Filipino", "fil"), + Pair("Čeština", "cs"), + Pair("Ελληνικά", "el"), + Pair("български", "bg"), + Pair("македонски", "mk"), + Pair("русский", "ru"), + Pair("українська", "uk"), + Pair("עברית", "iw"), + Pair("اردو", "ur"), + Pair("العربية", "ar"), + Pair("اللهجة النجدية", "ars"), + Pair("عربي شامي", "apc"), + Pair("فارسی", "fa"), + Pair("کوردیی ناوەندی", "ckb"), + Pair("नेपाली", "ne"), + Pair("हिन्दी", "hi"), + Pair("অসমীয়া", "as"), + Pair("বাংলা", "bn"), + Pair("ଓଡ଼ିଆ", "or"), + Pair("தமிழ்", "ta"), + Pair("ಕನ್ನಡ", "kn"), + Pair("മലയാളം", "ml"), + Pair("ဗမာစာ", "my"), + Pair("ትግርኛ", "ti"), + Pair("አማርኛ", "am"), + Pair("中文", "zh"), + Pair("日本語 (にほんご)", "ja"), + Pair("正體中文(臺灣)", "zh-TW"), + Pair("한국어", "ko"), /* end language list */ -).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top -class SettingsGeneral : PreferenceFragmentCompat() { +fun Pair.nameNextToFlagEmoji(): String { + // fallback to [A][A] -> [?] question mak flag + val flag = SubtitleHelper.getFlagFromIso(this.second) ?: "\ud83c\udde6\ud83c\udde6" + + return "$flag\u00a0${this.first}" // \u00a0 non-breaking space +} + +class SettingsGeneral : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) @@ -145,16 +156,23 @@ class SettingsGeneral : PreferenceFragmentCompat() { val lang: String, ) - private val pathPicker = getChooseFolderLauncher { uri, path -> - val context = context ?: AcraApplication.context ?: return@getChooseFolderLauncher - (path ?: uri.toString()).let { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(getString(R.string.download_path_key), uri.toString()) - .putString(getString(R.string.download_path_key_visual), it) - .apply() + companion object { + fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { + if (uri == null) return + + val context = context ?: CloudStreamApp.context ?: return + val visual = path ?: uri.toString() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(getString(R.string.download_path_key), uri.toString()) + putString(context.getString(R.string.download_path_key_visual), visual) + } } } + private val pathPicker = getChooseFolderLauncher { uri, path -> + pickDownloadPath(uri, path) + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_general, rootKey) @@ -166,22 +184,20 @@ class SettingsGeneral : PreferenceFragmentCompat() { } getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> - val tempLangs = appLanguages.toMutableList() val current = getCurrentLocale(pref.context) - val languageCodes = tempLangs.map { (_, _, iso) -> iso } - val languageNames = tempLangs.map { (emoji, name, iso) -> - val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } - "$flag $name" - } - val index = languageCodes.indexOf(current) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + val currentIndex = languageTagsIETF.indexOf(current) activity?.showDialog( - languageNames, index, getString(R.string.app_language), true, { } - ) { languageIndex -> + languageNames, currentIndex, getString(R.string.app_language), true, { } + ) { selectedLangIndex -> try { - val code = languageCodes[languageIndex] - CommonActivity.setLocale(activity, code) - settingsManager.edit().putString(getString(R.string.locale_key), code).apply() + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + settingsManager.edit { + putString(getString(R.string.locale_key), langTagIETF) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -203,7 +219,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { } fun showAdd() { - val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } + val providers = allProviders.distinctBy { it::class }.sortedBy { it.name } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -227,7 +243,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { val url = binding.siteUrlInput.text?.toString() val lang = binding.siteLangInput.text?.toString() val realLang = if (lang.isNullOrBlank()) provider.lang else lang - if (url.isNullOrBlank() || name.isNullOrBlank() || realLang.length != 2) { + if (url.isNullOrBlank() || name.isNullOrBlank()) { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -312,8 +328,8 @@ class SettingsGeneral : PreferenceFragmentCompat() { getString(R.string.dns_pref), true, {}) { - settingsManager.edit().putInt(getString(R.string.dns_pref), prefValues[it]).apply() - (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + settingsManager.edit { putInt(getString(R.string.dns_pref), prefValues[it]) } + (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } @@ -321,7 +337,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { fun getDownloadDirs(): List { return safe { context?.let { ctx -> - val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() + val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -337,18 +353,24 @@ class SettingsGeneral : PreferenceFragmentCompat() { } ?: emptyList() } - settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply() + settingsManager.edit { putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false) } getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue -> setKey(getString(R.string.jsdelivr_proxy_key), newValue) return@setOnPreferenceChangeListener true } + getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> + // Notify that the queue logic has been changed + DownloadQueueManager.forceRefreshQueue() + return@setOnPreferenceChangeListener true + } + getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = settingsManager.getString(getString(R.string.download_path_key_visual), null) - ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } + ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), @@ -367,10 +389,10 @@ class SettingsGeneral : PreferenceFragmentCompat() { // Sets both visual and actual paths. // key = used path // visual = visual path - settingsManager.edit() - .putString(getString(R.string.download_path_key), dirs[it]) - .putString(getString(R.string.download_path_key_visual), dirs[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.download_path_key), dirs[it]) + putString(getString(R.string.download_path_key_visual), dirs[it]) + } } } return@setOnPreferenceClickListener true @@ -393,10 +415,12 @@ class SettingsGeneral : PreferenceFragmentCompat() { if (beneneCount%20 == 0) { activity?.navigate(R.id.action_navigation_settings_general_to_easterEggMonkeFragment) } - settingsManager.edit().putInt( - getString(R.string.benene_count), - beneneCount - ).apply() + settingsManager.edit { + putInt( + getString(R.string.benene_count), + beneneCount + ) + } it.summary = getString(R.string.benene_count_text).format(beneneCount) } catch (e: Exception) { logError(e) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 0f7a24d15..0a0fb33c8 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 @@ -3,11 +3,13 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -20,18 +22,21 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsPlayer : PreferenceFragmentCompat() { +class SettingsPlayer : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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) @@ -43,12 +48,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { R.string.pref_category_gestures_key, R.string.rotate_video_key, R.string.auto_rotate_video_key, - R.string.speedup_key, - R.string.pip_enabled_key + R.string.speedup_key ), TV or EMULATOR ) - + getPref(R.string.preview_seekbar_key)?.hideOn(TV) getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) @@ -64,10 +68,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_length_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_length_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_length_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -82,10 +87,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.limit_title), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -100,30 +106,48 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.software_decoding), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.software_decoding_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.software_decoding_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } - 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) - val current = settingsManager.getInt(getString(R.string.prefer_limit_title_rez_key), 3) + getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false - activity?.showBottomDialog( - prefNames.toList(), - prefValues.indexOf(current), - getString(R.string.limit_title_rez), - true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) - .apply() + val prefNames = resources.getStringArray(R.array.title_info_pref_names) + val keys = resources.getStringArray(R.array.title_info_pref_values) + + // Player defaults + val playerDefaults = mapOf( + ctx.getString(R.string.show_name_key) to true, + ctx.getString(R.string.show_resolution_key) to true, + ctx.getString(R.string.show_media_info_key) to false + ) + + val selectedIndices = keys.map { key -> + settingsManager.getBoolean(key, playerDefaults[key] ?: false) + }.mapIndexedNotNull { index, enabled -> + if (enabled) index else null } - return@setOnPreferenceClickListener true + + activity?.showMultiDialog( + prefNames.toList(), + selectedIndices, + getString(R.string.limit_title_rez), + {} + ) { selected -> + settingsManager.edit { + for ((index, key) in keys.withIndex()) { + putBoolean(key, selected.contains(index)) + } + } + } + + true } getPref(R.string.hide_player_control_names_key)?.hideOn(TV) @@ -145,9 +169,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref), true, - {}) { - settingsManager.edit().putInt(getString(R.string.quality_pref_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.quality_pref_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -169,9 +195,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { 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() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -186,15 +214,19 @@ class SettingsPlayer : PreferenceFragmentCompat() { add("") addAll(players.map { it.uniqueId() }) } - val current = settingsManager.getString(getString(R.string.player_default_key), "") ?: "" + val current = + settingsManager.getString(getString(R.string.player_default_key), "") ?: "" activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.player_pref), true, - {}) { - settingsManager.edit().putString(getString(R.string.player_default_key), prefValues[it]).apply() + {} + ) { + settingsManager.edit { + putString(getString(R.string.player_default_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -209,6 +241,21 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.player_source_priority_key)?.setOnPreferenceClickListener { + ioSafe { + val defaultSources = QualityProfileDialog.getAllDefaultSources() + val activity = activity ?: return@ioSafe + activity.runOnUiThread { + QualityProfileDialog( + activity, + R.style.DialogFullscreenPlayer, + defaultSources, + ).show() + } + } + return@setOnPreferenceClickListener true + } + getPref(R.string.video_buffer_disk_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_size_names) val prefValues = resources.getIntArray(R.array.video_buffer_size_values) @@ -221,10 +268,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_disk_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -240,10 +288,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_size_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_size_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_size_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -251,20 +300,20 @@ class SettingsPlayer : PreferenceFragmentCompat() { getPref(R.string.video_buffer_clear_key)?.let { pref -> val cacheDir = context?.cacheDir ?: return@let - fun updateSummery() { + fun updateSummary() { try { - pref.summary = formatShortFileSize(view?.context, getFolderSize(cacheDir)) + pref.summary = formatShortFileSize(pref.context, getFolderSize(cacheDir)) } catch (e: Exception) { logError(e) } } - updateSummery() + updateSummary() pref.setOnPreferenceClickListener { try { cacheDir.deleteRecursively() - updateSummery() + updateSummary() } catch (e: Exception) { logError(e) } 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 cb7d25fd7..c8478a840 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,12 +2,13 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View -import androidx.navigation.NavOptions +import androidx.core.content.edit import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceFragmentCompat +import androidx.navigation.NavOptions import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat 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 @@ -16,10 +17,10 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsProviders : PreferenceFragmentCompat() { +class SettingsProviders : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) @@ -46,13 +47,15 @@ class SettingsProviders : PreferenceFragmentCompat() { names, currentList, getString(R.string.display_subbed_dubbed_settings), - {}) { selectedList -> + {} + ) { selectedList -> APIRepository.dubStatusActive = selectedList.map { dublist[it] }.toHashSet() - - settingsManager.edit().putStringSet( - this.getString(R.string.display_sub_key), - selectedList.map { names[it] }.toMutableSet() - ).apply() + settingsManager.edit { + putStringSet( + getString(R.string.display_sub_key), + selectedList.map { names[it] }.toMutableSet() + ) + } } } @@ -91,50 +94,46 @@ class SettingsProviders : PreferenceFragmentCompat() { names, currentList, getString(R.string.preferred_media_settings), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.prefer_media_type_key), - selectedList.map { it.toString() }.toMutableSet() - ).apply() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.prefer_media_type_key), + selectedList.map { it.toString() }.toMutableSet() + ) + } DataStoreHelper.currentHomePage = null - //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + //(context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } 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 + activity?.getApiProviderLangSettings()?.let { currentLangTags -> + val languagesTagName = APIHolder.apis.withLock { + listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } } - val currentList = current.map { - languages.indexOf(it) - } - - val names = languages.map { - if (it == AllLanguagesName) { - Pair(it, getString(R.string.all_languages_preference)) - } else { - val emoji = SubtitleHelper.getFlagFromIso(it) - val name = SubtitleHelper.fromTwoLettersToLanguage(it) - val fullName = "$emoji $name" - Pair(it, fullName) - } + val currentIndexList = currentLangTags.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } } activity?.showMultiDialog( - names.map { it.second }, - currentList, + languagesTagName.map { it.second }, + currentIndexList, getString(R.string.provider_lang_settings), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.provider_lang_key), - selectedList.map { names[it].first }.toMutableSet() - ).apply() - //APIRepository.providersActive = it.context.getApiSettings() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.provider_lang_key), + selectedList.map { languagesTagName[it].first }.toSet() + ) + } + // APIRepository.providersActive = it.context.getApiSettings() } } 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 6446ae75d..f4c522bf9 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 @@ -3,18 +3,22 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Build import android.os.Bundle import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager import androidx.preference.SeekBarPreference -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.clear +import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter +import com.lagradost.cloudstream3.ui.home.ParentItemAdapter +import com.lagradost.cloudstream3.ui.search.SearchAdapter 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.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn @@ -27,7 +31,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.toPx -class SettingsUI : PreferenceFragmentCompat() { +class SettingsUI : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) @@ -40,14 +44,27 @@ class SettingsUI : PreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) - getPref(R.string.random_button_key)?.hideOn(EMULATOR or TV) - - (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { perf, newValue -> + (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true - (perf.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) + (pref.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) return@setOnPreferenceChangeListener true } + getPref(R.string.bottom_title_key)?.setOnPreferenceChangeListener { _, _ -> + HomeChildItemAdapter.sharedPool.clear() + ParentItemAdapter.sharedPool.clear() + SearchAdapter.sharedPool.clear() + true + } + + getPref(R.string.poster_size_key)?.setOnPreferenceChangeListener { _, newValue -> + HomeChildItemAdapter.sharedPool.clear() + ParentItemAdapter.sharedPool.clear() + SearchAdapter.sharedPool.clear() + context?.let { HomeChildItemAdapter.updatePosterSize(it, newValue as? Int) } + true + } + getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.poster_ui_options) val keys = resources.getStringArray(R.array.poster_ui_options_values) @@ -63,12 +80,13 @@ class SettingsUI : PreferenceFragmentCompat() { prefNames.toList(), prefValues, getString(R.string.poster_ui_settings), - {}) { list -> - val edit = settingsManager.edit() - for ((i, key) in keys.withIndex()) { - edit.putBoolean(key, list.contains(i)) + {} + ) { list -> + settingsManager.edit { + for ((i, key) in keys.withIndex()) { + putBoolean(key, list.contains(i)) + } } - edit.apply() SearchResultBuilder.updateCache(it.context) } @@ -90,9 +108,9 @@ class SettingsUI : PreferenceFragmentCompat() { dismissCallback = {}, callback = { try { - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[it]) - .apply() + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[it]) + } context?.updateTv() activity?.recreate() } catch (e: Exception) { @@ -132,11 +150,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.app_theme_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.app_theme_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.app_theme_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -169,11 +188,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.primary_color_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.primary_color_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.primary_color_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -195,11 +215,14 @@ class SettingsUI : PreferenceFragmentCompat() { names, currentList, getString(R.string.pref_filter_search_quality), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.pref_filter_search_quality_key), - selectedList.map { it.toString() }.toMutableSet() - ).apply() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.pref_filter_search_quality_key), + selectedList.map { it.toString() }.toMutableSet() + ) + } } return@setOnPreferenceClickListener true @@ -217,9 +240,9 @@ class SettingsUI : PreferenceFragmentCompat() { showApply = true, dismissCallback = {}, callback = { selectedOption -> - settingsManager.edit() - .putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) - .apply() + settingsManager.edit { + putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) + } } ) return@setOnPreferenceClickListener true 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 bacca67ec..c04215594 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 @@ -5,12 +5,13 @@ import android.os.Bundle import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager -import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AutoDownloadMode +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app @@ -20,8 +21,8 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.services.BackupWorkManager +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV 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 @@ -31,13 +32,14 @@ import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded +import com.lagradost.cloudstream3.utils.InAppUpdater.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 com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader @@ -47,7 +49,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -class SettingsUpdates : PreferenceFragmentCompat() { +class SettingsUpdates : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) @@ -56,16 +58,17 @@ class SettingsUpdates : PreferenceFragmentCompat() { } private val pathPicker = getChooseFolderLauncher { uri, path -> - val context = context ?: AcraApplication.context ?: return@getChooseFolderLauncher + if(uri == null) return@getChooseFolderLauncher + + val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher (path ?: uri.toString()).let { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(getString(R.string.backup_path_key), uri.toString()) - .putString(getString(R.string.backup_dir_key), it) - .apply() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(getString(R.string.backup_path_key), uri.toString()) + putString(getString(R.string.backup_dir_key), it) + } } } - @Suppress("DEPRECATION_ERROR") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) @@ -86,11 +89,13 @@ class SettingsUpdates : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.backup_frequency), true, - {}) { index -> - settingsManager.edit() - .putInt(getString(R.string.automatic_backup_key), prefValues[index]).apply() + {} + ) { index -> + settingsManager.edit { + putInt(getString(R.string.automatic_backup_key), prefValues[index]) + } BackupWorkManager.enqueuePeriodicWork( - context ?: AcraApplication.context, + context ?: CloudStreamApp.context, prefValues[index].toLong() ) } @@ -117,7 +122,8 @@ class SettingsUpdates : PreferenceFragmentCompat() { dirs.indexOf(currentDir), getString(R.string.backup_path_title), true, - {}) { + {} + ) { // Last = custom if (it == dirs.size) { try { @@ -129,10 +135,10 @@ class SettingsUpdates : PreferenceFragmentCompat() { // Sets both visual and actual paths. // path = used uri // dir = dir path - settingsManager.edit() - .putString(getString(R.string.backup_path_key), dirs[it]) - .putString(getString(R.string.backup_dir_key), dirs[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.backup_path_key), dirs[it]) + putString(getString(R.string.backup_dir_key), dirs[it]) + } } } return@setOnPreferenceClickListener true @@ -157,7 +163,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { logError(e) // kinda ironic } - val adapter = LogcatAdapter(logList) + val adapter = LogcatAdapter().apply { submitList(logList) } binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) binding.logcatRecyclerView.adapter = adapter @@ -201,19 +207,21 @@ class SettingsUpdates : PreferenceFragmentCompat() { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) + // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 0) + settingsManager.getInt(getString(R.string.apk_installer_key), 1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {}) { num -> + {} + ) { num -> try { - settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), prefValues[num]) - .apply() + settingsManager.edit { + putInt(getString(R.string.apk_installer_key), prefValues[num]) + } } catch (e: Exception) { logError(e) } @@ -221,18 +229,29 @@ class SettingsUpdates : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.manual_check_update_key)?.setOnPreferenceClickListener { - ioSafe { - if (activity?.runAutoUpdate(false) == false) { - activity?.runOnUiThread { - showToast( - R.string.no_update_found, - Toast.LENGTH_SHORT - ) + getPref(R.string.manual_check_update_key)?.let { pref -> + pref.summary = BuildConfig.VERSION_NAME + pref.setOnPreferenceClickListener { + ioSafe { + if (activity?.runAutoUpdate(false) == false) { + activity?.runOnUiThread { + showToast( + R.string.no_update_found, + Toast.LENGTH_SHORT + ) + } } } + return@setOnPreferenceClickListener true + } + } + + getPref(R.string.install_prerelease_key)?.let { pref -> + pref.isVisible = BuildConfig.FLAVOR == "stable" + pref.setOnPreferenceClickListener { + activity?.installPreReleaseIfNeeded() + return@setOnPreferenceClickListener true } - return@setOnPreferenceClickListener true } getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { @@ -247,10 +266,12 @@ class SettingsUpdates : PreferenceFragmentCompat() { 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) } + {} + ) { num -> + settingsManager.edit { + putInt(getString(R.string.auto_download_plugins_key), prefValues[num]) + } + (context ?: CloudStreamApp.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 9c5229212..af0d3dfe7 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 @@ -4,10 +4,8 @@ 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 -import android.view.ViewGroup import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -15,7 +13,6 @@ 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 @@ -26,11 +23,12 @@ import com.lagradost.cloudstream3.databinding.FragmentExtensionsBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.utils.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.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog @@ -38,23 +36,13 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.setText -class ExtensionsFragment : Fragment() { - var binding: FragmentExtensionsBinding? = null - override fun onDestroyView() { - binding = null - super.onDestroyView() - } +class ExtensionsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) +) { - 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) - } + private val extensionViewModel: ExtensionsViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -65,8 +53,6 @@ class ExtensionsFragment : Fragment() { this.layoutParams = param } - private val extensionViewModel: ExtensionsViewModel by activityViewModels() - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -82,24 +68,25 @@ class ExtensionsFragment : Fragment() { extensionViewModel.loadRepositories() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - //context?.fixPaddingStatusbar(extensions_root) + override fun fixLayout(view: View) { + setSystemBarsPadding() + } + override fun onBindingCreated(binding: FragmentExtensionsBinding) { setUpToolbar(R.string.extensions) setToolBarScrollFlags() - binding?.repoRecyclerView?.apply { + binding.repoRecyclerView.apply { setLinearListLayout( isHorizontal = false, - nextUp = R.id.settings_toolbar, //FOCUS_SELF, // back has no id so we cant :pensive: + 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 -> + binding.addRepoButton.let { button -> button.post { setPadding( paddingLeft, @@ -113,10 +100,10 @@ class ExtensionsFragment : Fragment() { 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 + if (dy > 0) { // check for scroll down + binding.addRepoButton.shrink() // hide } else if (dy < -5) { - binding?.addRepoButton?.extend() // show + binding.addRepoButton.extend() // show } } } @@ -132,13 +119,14 @@ class ExtensionsFragment : Fragment() { }, { repo -> // Prompt user before deleting repo main { - val builder = AlertDialog.Builder(context ?: view.context) + val uiContext = context ?: binding.root.context + val builder = AlertDialog.Builder(uiContext) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { - RepositoryManager.removeRepository(view.context, repo) + RepositoryManager.removeRepository(uiContext.applicationContext, repo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -149,9 +137,7 @@ class ExtensionsFragment : Fragment() { } builder.setTitle(R.string.delete_repository) - .setMessage( - context?.getString(R.string.delete_repository_plugins) - ) + .setMessage(uiContext.getString(R.string.delete_repository_plugins)) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() @@ -160,37 +146,15 @@ class ExtensionsFragment : Fragment() { } observe(extensionViewModel.repositories) { - binding?.repoRecyclerView?.isVisible = it.isNotEmpty() - binding?.blankRepoScreen?.isVisible = it.isEmpty() - (binding?.repoRecyclerView?.adapter as? RepoAdapter)?.updateList(it) + binding.repoRecyclerView.isVisible = it.isNotEmpty() + binding.blankRepoScreen.isVisible = it.isEmpty() + (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) } - /*binding?.repoRecyclerView?.apply { - context?.let { ctx -> - layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) - } - }*/ - -// list_repositories?.setOnClickListener { -// // Open webview on tv if browser fails -// val isTv = isTvSettings() -// openBrowser(PUBLIC_REPOSITORIES_LIST, isTv, this) -// -// // Set clipboard on TV because the browser might not exist or work properly -// if (isTv) { -// val serviceClipboard = -// (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?) -// ?: return@setOnClickListener -// val clip = ClipData.newPlainText("Repository url", PUBLIC_REPOSITORIES_LIST) -// serviceClipboard.setPrimaryClip(clip) -// } -// } - observeNullable(extensionViewModel.pluginStats) { value -> - binding?.apply { + binding.apply { if (value == null) { pluginStorageAppbar.isVisible = false - return@observeNullable } @@ -210,7 +174,7 @@ class ExtensionsFragment : Fragment() { } } - binding?.pluginStorageAppbar?.setOnClickListener { + binding.pluginStorageAppbar.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -230,24 +194,24 @@ class ExtensionsFragment : Fragment() { val dialog = builder.create() dialog.show() - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( 0 - )?.text?.toString()?.let { copy -> - binding.repoUrlInput.setText(copy) + )?.text?.toString()?.let { copiedText -> + if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) { + // text is of format : + val (name, url) = copiedText.split(RepoAdapter.SHAREABLE_REPO_SEPARATOR, limit = 2) + binding.repoUrlInput.setText(url.trim()) + binding.repoNameInput.setText(name.trim()) + } else { + binding.repoUrlInput.setText(copiedText) + } } -// dialog.list_repositories?.setOnClickListener { -// // Open webview on tv if browser fails -// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) -// dialog.dismissSafe() -// } - -// dialog.text2?.text = provider.name binding.applyBtt.setOnClickListener secondListener@{ val name = binding.repoNameInput.text?.toString() + val urlInput = binding.repoUrlInput.text?.toString() ioSafe { - val url = binding.repoUrlInput.text?.toString() - ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } + val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) @@ -287,7 +251,7 @@ class ExtensionsFragment : Fragment() { } val isTv = isLayout(TV) - binding?.apply { + binding.apply { addRepoButton.isGone = isTv addRepoButtonImageviewHolder.isVisible = isTv @@ -300,4 +264,4 @@ class ExtensionsFragment : Fragment() { } 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 6d5e2ce27..482251b78 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 @@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugAssert 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 15228b260..d0f9ff565 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 @@ -8,29 +8,26 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.CloudStreamApp.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.utils.setText -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable -import org.junit.Assert -import org.junit.Test +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 @@ -41,60 +38,171 @@ data class PluginViewData( val isDownloaded: Boolean, ) +class RepositoryViewHolderState(view: ViewBinding) : ViewHolderState(view) { + // Store how many times this has called recycled, this is used to correctly sync text in jobs + var recycleCount = 0 +} + class PluginAdapter( val iconClickCallback: (Plugin) -> Unit -) : - RecyclerView.Adapter() { - 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 +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.plugin.second.internalName == b.plugin.second.internalName && a.plugin.first == b.plugin.first +})) { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val layout = if (isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) - return PluginViewHolder( + return RepositoryViewHolderState( RepositoryItemBinding.bind(inflated) // may crash ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PluginViewHolder -> { - holder.bind(plugins[position]) + override fun onClearView(holder: ViewHolderState) { + if (holder is RepositoryViewHolderState) { + holder.recycleCount += 1 + } + when (val binding = holder.view) { + is RepositoryItemBinding -> { + clearImage(binding.entryIcon) } } } - override fun getItemCount(): Int { - return plugins.size - } + @SuppressLint("SetTextI18n") + override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { + val binding = holder.view as? RepositoryItemBinding ?: return + val itemView = holder.itemView - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - PluginDiffCallback(this.plugins, newList) + val metadata = item.plugin.second + val disabled = metadata.status == PROVIDER_STATUS_DOWN + val name = metadata.name.removeSuffix("Provider") + val alpha = if (disabled) 0.6f else 1f + val isLocal = !item.plugin.second.url.startsWith("http") + binding.mainText.alpha = alpha + binding.subText.alpha = alpha + + val drawableInt = if (item.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) + + binding.actionButton.setOnClickListener { + iconClickCallback.invoke(item.plugin) + } + itemView.setOnClickListener { + if (isLocal) return@setOnClickListener + + val sheet = PluginDetailsFragment(item) + val activity = itemView.context.getActivity() as AppCompatActivity + sheet.show(activity.supportFragmentManager, "PluginDetails") + } + //if (itemView.context?.isTrueTvSettings() == false) { + // val siteUrl = metadata.repositoryUrl + // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { + // itemView.setOnClickListener { + // openBrowser(siteUrl) + // } + // } + //} + + if (item.isDownloaded) { + // On local plugins page the filepath is provided instead of url. + val plugin = + (PluginManager.urlPlugins[metadata.url] + ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin + + if (plugin?.openSettings != null) { + binding.actionSettings.isVisible = true + binding.actionSettings.setOnClickListener { + try { + plugin.openSettings?.invoke(itemView.context) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open $name settings: ${ + Log.getStackTraceString(e) + }" + ) + } + } + } else { + binding.actionSettings.isVisible = false + } + } else { + binding.actionSettings.isVisible = false + } + + val url = metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" ) - plugins.clear() - plugins.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - /* - private var storedPlugins: Array = reloadStoredPlugins() - - private fun reloadStoredPlugins(): Array { - return PluginManager.getPluginsOnline().also { storedPlugins = it } - }*/ - - // Clear coil image because setImageResource doesn't override - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is PluginViewHolder) { - holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) + if (url.isNullOrBlank()) { + binding.entryIcon.loadImage(R.drawable.ic_baseline_extension_24) + } else { + binding.entryIcon.loadImage( + url + ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } } - super.onViewRecycled(holder) + + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" + + if (metadata.language.isNullOrBlank()) { + binding.langIcon.isVisible = false + } else { + binding.langIcon.isVisible = true + binding.langIcon.text = getNameNextToFlagEmoji(metadata.language) ?: metadata.language + } + + //val oldRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount + + binding.extVotes.isVisible = false + + // Disable this for now as the vote api is down, this will also significantly improve the lag + // from doing all these network requests + /*if (!isLocal) { + ioSafe { + metadata.getVotes().main { votes -> + val currentRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount + + // Only set the text if the view is correctly rendered + if (currentRecycleCount == oldRecycleCount) { + binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(votes))) + binding.extVotes.isVisible = true + } + } + } + }*/ + + if (metadata.fileSize != null) { + binding.extFilesize.isVisible = true + binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) + } else { + binding.extFilesize.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() } companion object { + // A high count as we can render in the entire list as the same time + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 15) } + private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current @@ -103,14 +211,14 @@ class PluginAdapter( // 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 + /*@Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) Assert.assertEquals(256, findClosestBase2(170)) Assert.assertEquals(256, findClosestBase2(256)) Assert.assertEquals(512, findClosestBase2(257)) Assert.assertEquals(512, findClosestBase2(700)) - } + }*/ private val iconSizeExact = 32.toPx private val iconSize by lazy { @@ -131,136 +239,4 @@ class PluginAdapter( } } } - - inner class PluginViewHolder(val binding: RepositoryItemBinding) : - RecyclerView.ViewHolder(binding.root) { - - @SuppressLint("SetTextI18n") - fun bind( - data: PluginViewData, - ) { - val metadata = data.plugin.second - val disabled = metadata.status == PROVIDER_STATUS_DOWN - 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 - - 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) - - binding.actionButton.setOnClickListener { - iconClickCallback.invoke(data.plugin) - } - itemView.setOnClickListener { - if (isLocal) return@setOnClickListener - - val sheet = PluginDetailsFragment(data) - val activity = itemView.context.getActivity() as AppCompatActivity - sheet.show(activity.supportFragmentManager, "PluginDetails") - } - //if (itemView.context?.isTrueTvSettings() == false) { - // val siteUrl = metadata.repositoryUrl - // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { - // itemView.setOnClickListener { - // openBrowser(siteUrl) - // } - // } - //} - - if (data.isDownloaded) { - // On local plugins page the filepath is provided instead of url. - val plugin = - (PluginManager.urlPlugins[metadata.url] ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin - - if (plugin?.openSettings != null) { - binding.actionSettings.isVisible = true - binding.actionSettings.setOnClickListener { - try { - plugin.openSettings!!.invoke(itemView.context) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open $name settings: ${ - Log.getStackTraceString(e) - }" - ) - } - } - } else { - binding.actionSettings.isVisible = false - } - } else { - binding.actionSettings.isVisible = false - } - - binding.entryIcon.loadImage( - metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" - ) - ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } - - binding.extVersion.isVisible = true - binding.extVersion.text = "v${metadata.version}" - - if (metadata.language.isNullOrBlank()) { - binding.langIcon.isVisible = false - } else { - binding.langIcon.isVisible = true - binding.langIcon.text = - "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" - } - - binding.extVotes.isVisible = false - if (!isLocal) { - ioSafe { - metadata.getVotes().main { - binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(it))) - binding.extVotes.isVisible = true - } - } - } - - - if (metadata.fileSize != null) { - binding.extFilesize.isVisible = true - binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) - } else { - binding.extFilesize.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() - } - } -} - -class PluginDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].plugin.second.internalName == newList[newItemPosition].plugin.second.internalName && oldList[oldItemPosition].plugin.first == newList[newItemPosition].plugin.first - - 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/settings/extensions/PluginDetailsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt index 80be3cf4b..0dcbece6c 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 @@ -1,33 +1,36 @@ 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 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.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseBottomSheetDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -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.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.toPx - -class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragment() { +class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFragment( + BaseFragment.BindingCreator.Inflate(FragmentPluginDetailsBinding::inflate) +) { companion object { private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { @@ -42,26 +45,17 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen } } - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - 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) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { val metadata = data.plugin.second - binding?.apply { + binding.apply { pluginIcon.loadImage(metadata.iconUrl?.replace("%size%", "$iconSize") ?.replace("%exact_size%", "$iconSizeExact")) { error { getImageFromDrawable(context ?: return@error null , R.drawable.ic_baseline_extension_24) } @@ -85,9 +79,9 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen ", " ) pluginLang.text = if (metadata.language == null) - getString(R.string.no_data) - else - "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + getString(R.string.no_data) + else + getNameNextToFlagEmoji(metadata.language) ?: metadata.language githubBtn.setOnClickListener { if (metadata.repositoryUrl != null) { 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 4878049b4..534ffa62a 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 @@ -1,70 +1,62 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels 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.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.BaseFragment 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.setRecycledViewPool 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.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx const val PLUGINS_BUNDLE_NAME = "name" const val PLUGINS_BUNDLE_URL = "url" const val PLUGINS_BUNDLE_LOCAL = "isLocal" -class PluginsFragment : Fragment() { - override fun onCreateView( - 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) - } +class PluginsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) +) { + + private val pluginViewModel: PluginsViewModel by activityViewModels() override fun onDestroyView() { - binding = null + pluginViewModel.clear() // clear for the next observe super.onDestroyView() } - private val pluginViewModel: PluginsViewModel by activityViewModels() - var binding: FragmentPluginsBinding? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + setSystemBarsPadding() + } + override fun onBindingCreated(binding: FragmentPluginsBinding) { // Since the ViewModel is getting reused the tvTypes must be cleared between uses pluginViewModel.tvTypes.clear() - pluginViewModel.languages = listOf() - pluginViewModel.search(null) + pluginViewModel.selectedLanguages = listOf() + pluginViewModel.clear() // Filter by language set on preferred media activity?.let { val providerLangs = it.getApiProviderLangSettings().toList() if (!providerLangs.contains(AllLanguagesName)) { - pluginViewModel.languages = mutableListOf("none") + providerLangs - //Log.i("DevDebug", "providerLang => ${pluginViewModel.languages.toJson()}") + pluginViewModel.selectedLanguages = mutableListOf("none") + providerLangs } } @@ -72,16 +64,16 @@ class PluginsFragment : Fragment() { 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) + val downloadAllButton = binding.settingsToolbar.menu?.findItem(R.id.download_all) if (url == null || name == null) { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() return } setToolBarScrollFlags() setUpToolbar(name) - binding?.settingsToolbar?.apply { + binding.settingsToolbar.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { R.id.download_all -> { @@ -89,24 +81,35 @@ class PluginsFragment : Fragment() { } 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 languagesTagName = pluginViewModel.pluginLanguages + .map { langTag -> + Pair( + langTag, + getNameNextToFlagEmoji(langTag) ?: langTag + ) } - val selectedList = - pluginViewModel.languages.map { languageCodes.indexOf(it) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + .toMutableList() + + // Move "none" to 1st position as it's special code to indicate unknown/missing language + if (languagesTagName.remove(Pair("none", "none"))) { + languagesTagName.add(0, Pair("none", getString(R.string.no_data))) + } + + val currentIndexList = pluginViewModel.selectedLanguages.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } + } activity?.showMultiDialog( - languageNames, - selectedList, + languagesTagName.map { it.second }, + currentIndexList, getString(R.string.provider_lang_settings), - {}) { newList -> - pluginViewModel.languages = newList.map { languageCodes[it] } + {} + ) { selectedList -> + pluginViewModel.selectedLanguages = + selectedList.map { languagesTagName[it].first } pluginViewModel.updateFilteredPlugins() } } @@ -124,7 +127,7 @@ class PluginsFragment : Fragment() { if (searchView?.isIconified == false) { searchView.isIconified = true } else { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() } } searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> @@ -149,46 +152,46 @@ class PluginsFragment : Fragment() { // Because onActionViewCollapsed doesn't wanna work we need this workaround :( - binding?.pluginRecyclerView?.setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - - binding?.pluginRecyclerView?.adapter = - PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - } + binding.pluginRecyclerView.apply { + setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + setRecycledViewPool(PluginAdapter.sharedPool) + adapter = + PluginAdapter { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + } + } if (isLayout(TV or EMULATOR)) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - binding?.pluginRecyclerView?.setPadding(0, 0, 0, 200.toPx) + binding.pluginRecyclerView.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (binding?.pluginRecyclerView?.adapter as? PluginAdapter)?.updateList(list) - - if (scrollToTop) - binding?.pluginRecyclerView?.scrollToPosition(0) + (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(list) + if (scrollToTop) { + binding.pluginRecyclerView.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 + binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() - binding?.tvtypesChipsScroll?.root?.isVisible = false + binding.tvtypesChipsScroll.root.isVisible = false } else { pluginViewModel.updatePluginList(context, url) - binding?.tvtypesChipsScroll?.root?.isVisible = true + binding.tvtypesChipsScroll.root.isVisible = true // not needed for users but may be useful for devs downloadAllButton?.isVisible = BuildConfig.DEBUG - - bindChips( - binding?.tvtypesChipsScroll?.tvtypesChips, + binding.tvtypesChipsScroll.tvtypesChips, emptyList(), TvType.entries.toList(), callback = { list -> 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 a6f914898..0cbef9cf2 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 @@ -23,9 +23,10 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import me.xdrop.fuzzywuzzy.FuzzySearch +import com.lagradost.cloudstream3.utils.Levenshtein import java.io.File +// String => repository url typealias Plugin = Pair /** * The boolean signifies if the plugin list should be scrolled to the top, used for searching. @@ -36,13 +37,28 @@ class PluginsViewModel : ViewModel() { /** plugins is an unaltered list of plugins */ private var plugins: List = emptyList() + set(value) { + // Also set all the plugin languages for easier filtering + value.map { pluginViewData -> + val language = pluginViewData.plugin.second.language?.lowercase() + pluginLanguages.add( + when { + language.isNullOrBlank() -> "none" + else -> language.lowercase() + } + ) + // not sorting as most likely this is a language tag instead of name + } + field = value + } + var pluginLanguages = mutableSetOf() // set to avoid duplicates /** filteredPlugins is a subset of plugins following the current search query and tv type selection */ private var _filteredPlugins = MutableLiveData() var filteredPlugins: LiveData = _filteredPlugins val tvTypes = mutableListOf() - var languages = listOf() + var selectedLanguages = listOf() private var currentQuery: String? = null companion object { @@ -112,6 +128,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -163,6 +180,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, isEnabled @@ -213,12 +231,12 @@ class PluginsViewModel : ViewModel() { } private fun List.filterLang(): List { - if (languages.isEmpty()) return this + if (selectedLanguages.isEmpty()) return this // do not filter return this.filter { if (it.plugin.second.language == null) { - return@filter languages.contains("none") + return@filter selectedLanguages.contains("none") } - languages.contains(it.plugin.second.language) + selectedLanguages.contains(it.plugin.second.language?.lowercase()) } } @@ -227,7 +245,12 @@ class PluginsViewModel : ViewModel() { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } + this.sortedBy { + -Levenshtein.partialRatio( + it.plugin.second.name.lowercase(), + query.lowercase() + ) + } } } @@ -237,6 +260,13 @@ class PluginsViewModel : ViewModel() { ) } + fun clear() { + currentQuery = null + _filteredPlugins.postValue( + false to emptyList() + ) + } + fun updatePluginList(context: Context?, repositoryUrl: String) = viewModelScope.launchSafe { if (context == null) return@launchSafe Log.i(TAG, "updatePluginList = $repositoryUrl") 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 42550091a..0f9bf5f58 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 @@ -2,19 +2,19 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.view.LayoutInflater 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.utils.txt +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.txt class RepoAdapter( val isSetup: Boolean, @@ -22,10 +22,11 @@ class RepoAdapter( val imageClickCallback: RepoAdapter.(RepositoryData) -> Unit, /** In setup mode the trash icons will be replaced with download icons */ ) : - RecyclerView.Adapter() { - private val repositories: MutableList = mutableListOf() + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.url == b.url + })) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -34,130 +35,97 @@ class RepoAdapter( LayoutInflater.from(parent.context), parent, false - ) //R.layout.repository_item_tv else R.layout.repository_item - return RepoViewHolder( - layout ) + return ViewHolderState(layout) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is RepoViewHolder -> { - holder.bind(repositories[position]) - } + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is RepositoryItemBinding -> clearImage(binding.entryIcon) + is RepositoryItemTvBinding -> clearImage(binding.entryIcon) } } - override fun getItemCount(): Int { - return repositories.size - } + override fun onBindContent(holder: ViewHolderState, item: RepositoryData, position: Int) { + val isPrebuilt = PREBUILT_REPOSITORIES.contains(item) + val drawable = + if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 + when (val binding = holder.view) { + 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) + } - fun updateList(newList: Array) { - val diffResult = DiffUtil.calculateDiff( - RepoDiffCallback(this.repositories, newList) - ) + actionButton.setOnClickListener { + imageClickCallback(item) + } - repositories.clear() - repositories.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - // Clear coil image because setImageResource doesn't override - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is RepoViewHolder) { - when(holder.binding){ - is RepositoryItemBinding -> holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) - is RepositoryItemTvBinding -> holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) - } - } - super.onViewRecycled(holder) - } - - inner class RepoViewHolder( - val binding: ViewBinding - ) : - RecyclerView.ViewHolder(binding.root) { - fun bind( - repositoryData: RepositoryData - ) { - val isPrebuilt = PREBUILT_REPOSITORIES.contains(repositoryData) - val drawable = - if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 - when (binding) { - is RepositoryItemTvBinding -> { - binding.apply { - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - actionButton.setImageResource(drawable) - } - - actionButton.setOnClickListener { - imageClickCallback(repositoryData) - } - - repositoryItemRoot.setOnClickListener { - clickCallback(repositoryData) - } - mainText.text = repositoryData.name - subText.text = repositoryData.url - if(!repositoryData.iconUrl.isNullOrEmpty()){ - entryIcon.loadImage(repositoryData.iconUrl){ - error(getImageFromDrawable(itemView.context,R.drawable.ic_github_logo)) - } + repositoryItemRoot.setOnClickListener { + clickCallback(item) + } + mainText.text = item.name + subText.text = item.url + if (!item.iconUrl.isNullOrEmpty()) { + entryIcon.loadImage(item.iconUrl) { + error( + getImageFromDrawable( + binding.root.context, + R.drawable.ic_github_logo + ) + ) } + } else { + entryIcon.loadImage(R.drawable.ic_github_logo) } } + } - 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) - } + 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) - } + actionButton.setOnClickListener { + imageClickCallback(item) + } - repositoryItemRoot.setOnClickListener { - clickCallback(repositoryData) - } + repositoryItemRoot.setOnClickListener { + clickCallback(item) + } - repositoryItemRoot.setOnLongClickListener { - val shareableRepoData = "${repositoryData.name} : \n ${repositoryData.url}" - clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) - true - } + repositoryItemRoot.setOnLongClickListener { + val shareableRepoData = + "${item.name}$SHAREABLE_REPO_SEPARATOR\n ${item.url}" + clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) + true + } - mainText.text = repositoryData.name - subText.text = repositoryData.url - if(!repositoryData.iconUrl.isNullOrEmpty()){ - entryIcon.loadImage(repositoryData.iconUrl){ - error(getImageFromDrawable(itemView.context,R.drawable.ic_github_logo)) - } + mainText.text = item.name + subText.text = item.url + if (!item.iconUrl.isNullOrEmpty()) { + entryIcon.loadImage(item.iconUrl) { + error( + getImageFromDrawable( + binding.root.context, + R.drawable.ic_github_logo + ) + ) } + } else { + entryIcon.loadImage(R.drawable.ic_github_logo) } } } } } -} -class RepoDiffCallback( - private val oldList: List, - private val newList: Array -) : - 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] + companion object { + const val SHAREABLE_REPO_SEPARATOR = " : " + } } \ No newline at end of file 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 index 921ac0674..4ec005a09 100644 --- 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 @@ -1,41 +1,35 @@ 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.safe import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar - -class TestFragment : Fragment() { +class TestFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) +) { private val testViewModel: TestViewModel by activityViewModels() - var binding: FragmentTestingBinding? = null - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + setSystemBarsPadding() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: FragmentTestingBinding) { setUpToolbar(R.string.category_provider_test) setToolBarScrollFlags() - super.onViewCreated(view, savedInstanceState) - binding?.apply { - providerTestRecyclerView.adapter = TestResultAdapter( - mutableListOf() - ) + binding.apply { + providerTestRecyclerView.adapter = TestResultAdapter() testViewModel.init() if (testViewModel.isRunningTest) { @@ -46,10 +40,10 @@ class TestFragment : Fragment() { providerTest.setProgress(passed, failed, total) } - observeNullable(testViewModel.providerResults) { + observe(testViewModel.providerResults) { safe { val newItems = it.sortedBy { api -> api.first.name } - (providerTestRecyclerView.adapter as? TestResultAdapter)?.updateList( + (providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList( newItems ) } @@ -96,13 +90,4 @@ class TestFragment : Fragment() { } } } - - 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 index bad58a0e7..c53ff1fcf 100644 --- 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 @@ -7,7 +7,6 @@ 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 @@ -15,103 +14,117 @@ 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.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState 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 - +class TestResultAdapter() : + NoStateAdapter>( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.first.name == b.first.name && a.first.mainUrl == b.first.mainUrl + }, + contentSame = { a, b -> + a == b + }) + ) { + companion object { 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 + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ProviderTestItemBinding ?: return + clearImage(binding.actionButton) + } - 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 - } + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ProviderTestItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindContent( + holder: ViewHolderState, + item: Pair, + position: Int + ) { + val binding = holder.view as? ProviderTestItemBinding ?: return + val (api, result) = item + + val itemView = holder.itemView + + val languageText: TextView = binding.langIcon + val providerTitle: TextView = binding.mainText + val statusText: TextView = binding.passedFailedMarker + val failDescription: TextView = binding.failDescription + val logButton: ImageView = binding.actionButton + + 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_failed to R.color.colorTestFail + 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)) + 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" } ?: "") + 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() + 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) { _, _ -> } + 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 + 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) + 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) - } + runOnMainThread { + if (success) { + showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) + } else { + showToast(R.string.error, Toast.LENGTH_SHORT) } } } } - - builder.show() } + + 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 index eea495a26..65ed47a54 100644 --- 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 @@ -9,6 +9,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import com.google.android.material.button.MaterialButton @@ -59,10 +60,9 @@ class TestView @JvmOverloads constructor( 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() + context.withStyledAttributes(it, R.styleable.TestView) { + mainSectionHeader?.text = getString(R.styleable.TestView_header_text) + } } playPauseButton?.setOnClickListener { 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 index 818f1fd79..22500d931 100644 --- 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 @@ -5,7 +5,7 @@ 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.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,7 +40,7 @@ class TestViewModel : ViewModel() { get() = scope != null private var filter = ProviderFilter.All - private val providers = threadSafeListOf>() + private val providers = atomicListOf>() private var passed = 0 private var failed = 0 private var total = 0 @@ -51,9 +51,9 @@ class TestViewModel : ViewModel() { } private fun postProviders() { - synchronized(providers) { + providers.withLock { val filtered = when (filter) { - ProviderFilter.All -> providers + ProviderFilter.All -> providers.toList() ProviderFilter.Passed -> providers.filter { it.second.success } ProviderFilter.Failed -> providers.filter { !it.second.success } } @@ -68,7 +68,7 @@ class TestViewModel : ViewModel() { } private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { - synchronized(providers) { + providers.withLock { val index = providers.indexOfFirst { it.first == api } if (index == -1) { providers.add(api to results) @@ -81,14 +81,14 @@ class TestViewModel : ViewModel() { } fun init() { - total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } + total = APIHolder.allProviders.withLock { APIHolder.allProviders.size } updateProgress() } fun startTest() { scope = CoroutineScope(Dispatchers.Default) - val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } + val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() } total = apis.size failed = 0 passed = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt index 9e126b7a6..dfc931174 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt @@ -4,14 +4,17 @@ import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment -import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.safefile.SafeFile fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> // It lies, it can be null if file manager quits. - if (uri == null) return@registerForActivityResult - val context = context ?: AcraApplication.context ?: return@registerForActivityResult + if(uri == null) { + dirSelected(null, null) + return@registerForActivityResult + } + val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult // RW perms for the path val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION 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 0dccd5cc4..8c2e8e344 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 @@ -1,11 +1,8 @@ package com.lagradost.cloudstream3.ui.setup import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup 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.MainActivity.Companion.afterRepositoryLoadedEvent @@ -14,13 +11,15 @@ import com.lagradost.cloudstream3.databinding.FragmentSetupExtensionsBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES +import com.lagradost.cloudstream3.ui.BaseFragment 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 com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding - -class SetupFragmentExtensions : Fragment() { +class SetupFragmentExtensions : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupExtensionsBinding::inflate) +) { companion object { const val SETUP_EXTENSION_BUNDLE_IS_SETUP = "isSetup" @@ -34,24 +33,6 @@ 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) - } - - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories @@ -62,18 +43,21 @@ class SetupFragmentExtensions : Fragment() { afterRepositoryLoadedEvent -= ::setRepositories } + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) + } + private fun setRepositories(success: Boolean = true) { main { val repositories = RepositoryManager.getRepositories() + PREBUILT_REPOSITORIES val hasRepos = repositories.isNotEmpty() binding?.repoRecyclerView?.isVisible = hasRepos binding?.blankRepoScreen?.isVisible = !hasRepos -// view_public_repositories_button?.isVisible = hasRepos if (hasRepos) { binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) - }).apply { updateList(repositories) } + }).apply { submitList(repositories.toList()) } } // else { // list_repositories?.setOnClickListener { @@ -84,19 +68,12 @@ class SetupFragmentExtensions : Fragment() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) + override fun onBindingCreated(binding: FragmentSetupExtensionsBinding) { val isSetup = arguments?.getBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP) ?: false -// view_public_repositories_button?.setOnClickListener { -// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) -// } - safe { - // val ctx = context ?: return@safe setRepositories() - binding?.apply { + binding.apply { if (!isSetup) { nextBtt.setText(R.string.setup_done) } @@ -107,7 +84,7 @@ class SetupFragmentExtensions : Fragment() { if (isSetup) if ( // If any available languages - synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } + apis.distinctBy { it.lang }.size > 1 ) { findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) } else { @@ -123,6 +100,4 @@ class SetupFragmentExtensions : Fragment() { } } } - - -} \ No newline at end of file +} 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 a908db55a..e96a662c3 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 @@ -1,62 +1,45 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment +import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity -import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseFragment 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 com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" -class SetupFragmentLanguage : Fragment() { - var binding: FragmentSetupLanguageBinding? = null +class SetupFragmentLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) +) { - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - 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) - } - - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - + override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { // We don't want a crash for all users safe { - fixPaddingStatusbar(binding?.setupRoot) - val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - binding?.apply { + binding.apply { // Icons may crash on some weird android versions? safe { val drawable = when { @@ -68,24 +51,21 @@ class SetupFragmentLanguage : Fragment() { } val current = getCurrentLocale(ctx) - val languageCodes = appLanguages.map { it.third } - val languageNames = appLanguages.map { (emoji, name, iso) -> - val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } - "$flag $name" - } - val index = languageCodes.indexOf(current) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + val currentIndex = languageTagsIETF.indexOf(current) arrayAdapter.addAll(languageNames) listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked(index, true) + listview1.setItemChecked(currentIndex, true) - listview1.setOnItemClickListener { _, _, position, _ -> - val code = languageCodes[position] - CommonActivity.setLocale(activity, code) - settingsManager.edit().putString(getString(R.string.locale_key), code) - .apply() - activity?.recreate() + listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + settingsManager.edit { + putString(getString(R.string.locale_key), langTagIETF) + } } nextBtt.setOnClickListener { @@ -108,7 +88,6 @@ class SetupFragmentLanguage : Fragment() { findNavController().navigate(R.id.navigation_home) } } - } } -} \ No newline at end of file +} 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 85eabefa4..4a8e784a1 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 @@ -1,45 +1,27 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter -import androidx.fragment.app.Fragment +import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import org.acra.ACRA +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentLayout : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLayoutBinding::inflate) +) { -class SetupFragmentLayout : Fragment() { - - var binding: FragmentSetupLayoutBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - 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) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) - + override fun onBindingCreated(binding: FragmentSetupLayoutBinding) { safe { val ctx = context ?: return@safe @@ -55,7 +37,7 @@ class SetupFragmentLayout : Fragment() { ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) - binding?.apply { + binding.apply { listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE listview1.setItemChecked( @@ -63,28 +45,11 @@ class SetupFragmentLayout : Fragment() { ) listview1.setOnItemClickListener { _, _, position, _ -> - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[position]) - .apply() + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[position]) + } 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) - - 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) @@ -97,4 +62,4 @@ class SetupFragmentLayout : Fragment() { } } } -} \ 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 9db967dcb..8da121daa 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 @@ -1,47 +1,30 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter +import androidx.core.content.edit import androidx.core.util.forEach -import androidx.fragment.app.Fragment 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.safe +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentMedia : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupMediaBinding::inflate) +) { -class SetupFragmentMedia : Fragment() { - var binding: FragmentSetupMediaBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - 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) - } - - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentSetupMediaBinding) { safe { - fixPaddingStatusbar(binding?.setupRoot) - val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) @@ -52,7 +35,7 @@ class SetupFragmentMedia : Fragment() { val selected = mutableListOf() arrayAdapter.addAll(names) - binding?.apply { + binding.apply { listview1.let { it.adapter = arrayAdapter it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE @@ -71,9 +54,9 @@ class SetupFragmentMedia : Fragment() { val itemVal = TvType.valueOf(item) itemVal.ordinal.toString() }.toSet() - settingsManager.edit() - .putStringSet(getString(R.string.prefer_media_type_key), prefValues) - .apply() + settingsManager.edit { + putStringSet(getString(R.string.prefer_media_type_key), prefValues) + } // Regenerate set homepage DataStoreHelper.currentHomePage = null @@ -90,4 +73,4 @@ class SetupFragmentMedia : Fragment() { } } } -} \ 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 353e735e9..c18be8a2f 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 @@ -1,47 +1,31 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter +import androidx.core.content.edit import androidx.core.util.forEach -import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.AllLanguagesName -import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -class SetupFragmentProviderLanguage : Fragment() { - var binding: FragmentSetupProviderLanguagesBinding? = null +class SetupFragmentProviderLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupProviderLanguagesBinding::inflate) +) { - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - 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) - } - - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) - + override fun onBindingCreated(binding: FragmentSetupProviderLanguagesBinding) { safe { val ctx = context ?: return@safe @@ -50,51 +34,47 @@ class SetupFragmentProviderLanguage : Fragment() { val arrayAdapter = ArrayAdapter(ctx, 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 currentLangTags = ctx.getApiProviderLangSettings() - val currentList = - current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO + val languagesTagName = APIHolder.apis.withLock { + listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji + } - val languageNames = langs.map { - if (it == AllLanguagesName) { - getString(R.string.all_languages_preference) - } else { - val emoji = SubtitleHelper.getFlagFromIso(it) - val name = SubtitleHelper.fromTwoLettersToLanguage(it) - "$emoji $name" + val currentIndexList = currentLangTags.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } + }.filter { it > -1 } + + arrayAdapter.addAll(languagesTagName.map { it.second }) + binding.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + currentIndexList.forEach { + listview1.setItemChecked(it, true) + } + + listview1.setOnItemClickListener { _, _, _, _ -> + val selectedLanguages = mutableSetOf() + listview1.checkedItemPositions?.forEach { key, value -> + if (value) selectedLanguages.add(languagesTagName[key].first) + } + settingsManager.edit { + putStringSet( + ctx.getString(R.string.provider_lang_key), + selectedLanguages.toSet() + ) + } + } + + nextBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) + } + + prevBtt.setOnClickListener { + findNavController().popBackStack() } } - - arrayAdapter.addAll(languageNames) - binding?.apply { - listview1.adapter = arrayAdapter - listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - currentList.forEach { - listview1.setItemChecked(it, true) - } - - listview1.setOnItemClickListener { _, _, _, _ -> - val currentLanguages = mutableListOf() - listview1.checkedItemPositions?.forEach { key, value -> - if (value) currentLanguages.add(langs[key]) - } - settingsManager.edit().putStringSet( - ctx.getString(R.string.provider_lang_key), - currentLanguages.toSet() - ).apply() - } - - nextBtt.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) - } - - prevBtt.setOnClickListener { - findNavController().popBackStack() - } } } } - - -} \ No newline at end of file +} 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 c76a218e5..f9b1cb1fe 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 @@ -7,13 +7,12 @@ import android.graphics.Color import android.os.Bundle import android.util.DisplayMetrics import android.util.TypedValue -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.TextView import android.widget.Toast -import androidx.fragment.app.Fragment +import androidx.annotation.OptIn import androidx.media3.common.text.Cue +import androidx.media3.common.util.UnstableApi 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 @@ -21,19 +20,21 @@ import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.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.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -50,8 +51,10 @@ data class SaveChromeCaptionStyle( @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() { + +class ChromecastSubtitlesFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(ChromecastSubtitleSettingsBinding::inflate) +) { companion object { val applyStyleEvent = Event() @@ -142,23 +145,6 @@ class ChromecastSubtitlesFragment : Fragment() { //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() - } - private lateinit var state: SaveChromeCaptionStyle private var hide: Boolean = true @@ -167,26 +153,29 @@ class ChromecastSubtitlesFragment : Fragment() { onColorSelectedEvent -= ::onColorSelected } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } + + override fun onBindingCreated(binding: ChromecastSubtitleSettingsBinding) { hide = arguments?.getBoolean("hide") ?: true onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - fixPaddingStatusbar(binding?.subsRoot) - state = getCurrentSavedStyle() updateState() val isTvSettings = isLayout(TV or EMULATOR) - fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvSettings } fun View.setup(id: Int) { setFocusableInTv() - this.setOnClickListener { activity?.let { ColorPickerDialog.newBuilder() @@ -204,20 +193,19 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.apply { + binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) } - val dismissCallback = { if (hide) activity?.hideSystemUI() } - binding?.subsEdgeType?.setFocusableInTv() - binding?.subsEdgeType?.setOnClickListener { textView -> + binding.subsEdgeType.setFocusableInTv() + binding.subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -254,15 +242,15 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.subsEdgeType?.setOnLongClickListener { + binding.subsEdgeType.setOnLongClickListener { state.edgeType = defaultState.edgeType updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding?.subsFontSize?.setFocusableInTv() - binding?.subsFontSize?.setOnClickListener { textView -> + binding.subsFontSize.setFocusableInTv() + binding.subsFontSize.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -295,17 +283,15 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.subsFontSize?.setOnLongClickListener { _ -> + binding.subsFontSize.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - - - binding?.subsFont?.setFocusableInTv() - binding?.subsFont?.setOnClickListener { textView -> + binding.subsFont.setFocusableInTv() + binding.subsFont.setOnClickListener { textView -> val fontTypes = listOf( null to textView.context.getString(R.string.normal), "Droid Sans" to "Droid Sans", @@ -329,24 +315,30 @@ class ChromecastSubtitlesFragment : Fragment() { updateState() } } - binding?.subsFont?.setOnLongClickListener { _ -> + binding.subsFont.setOnLongClickListener { _ -> state.fontFamily = defaultState.fontFamily updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding?.cancelBtt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { activity?.popCurrentPage() } - binding?.applyBtt?.setOnClickListener { + binding.applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } - binding?.subtitleText?.apply { + + setSubtitleCues(binding) + } + + @OptIn(UnstableApi::class) + private fun setSubtitleCues(binding: ChromecastSubtitleSettingsBinding) { + binding.subtitleText.apply { setCues( listOf( Cue.Builder() 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 e5671fa80..5f716cca3 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 @@ -12,16 +12,14 @@ import android.text.SpannableString import android.text.style.StyleSpan import android.util.DisplayMetrics import android.util.TypedValue -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.annotation.FontRes import androidx.annotation.OptIn import androidx.annotation.Px +import androidx.core.content.edit import androidx.core.content.res.ResourcesCompat -import androidx.fragment.app.DialogFragment import androidx.media3.common.text.Cue import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat @@ -29,23 +27,29 @@ import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey 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.BaseDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.player.CustomDecoder +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment import com.lagradost.cloudstream3.ui.player.OutlineSpan import com.lagradost.cloudstream3.ui.player.RoundedBackgroundColorSpan +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -56,10 +60,11 @@ 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, + @OptIn(UnstableApi::class) @JsonProperty("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, @JsonProperty("edgeColor") var edgeColor: Int, @FontRes @@ -81,24 +86,35 @@ data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( @JsonProperty("italic") var italic: Boolean = false, /** in px, background radius, aka how round the background (backgroundColor) on each row is **/ @JsonProperty("backgroundRadius") var backgroundRadius: Float? = null, + /** The SSA_ALIGNMENT */ + @JsonProperty("alignment") var alignment: Int? = null, ) const val DEF_SUBS_ELEVATION = 20 -@OptIn(androidx.media3.common.util.UnstableApi::class) -class SubtitlesFragment : DialogFragment() { +@OptIn(UnstableApi::class) +class SubtitlesFragment : BaseDialogFragment( + BaseFragment.BindingCreator.Inflate(SubtitleSettingsBinding::inflate) +) { companion object { val applyStyleEvent = Event() private val captionRegex = Regex("""(-\s?|)[\[({][\S\s]*?[])}]\s*""") - fun setSubtitleViewStyle(view: SubtitleView?, data: SaveCaptionStyle) { + fun setSubtitleViewStyle( + view: SubtitleView?, + data: SaveCaptionStyle, + applyElevation: Boolean + ) { if (view == null) return val ctx = view.context ?: return val style = ctx.fromSaveToStyle(data) view.setStyle(style) - view.setPadding( - view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom - ) + + if (applyElevation) { + view.setPadding( + view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom + ) + } // we default to 25sp, this is needed as RoundedBackgroundColorSpan breaks on override sizes val size = data.fixedTextSize ?: 25.0f @@ -192,7 +208,8 @@ class SubtitlesFragment : DialogFragment() { } } - return this + // 6. set alignment + return this.setSubtitleAlignment(style.alignment) } private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { @@ -276,11 +293,11 @@ class SubtitlesFragment : DialogFragment() { return TypedValue.applyDimension(unit, size, metrics).toInt() } - fun getDownloadSubsLanguageISO639_1(): List { + fun getDownloadSubsLanguageTagIETF(): List { return getKey(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") } - fun getAutoSelectLanguageISO639_1(): String { + fun getAutoSelectLanguageTagIETF(): String { return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } } @@ -312,7 +329,7 @@ class SubtitlesFragment : DialogFragment() { private fun Context.updateState() { val text = getString(R.string.subtitles_example_text) val fixedText = SpannableString.valueOf(if (state.upperCase) text.uppercase() else text) - setSubtitleViewStyle(binding?.subtitleText, state) + setSubtitleViewStyle(binding?.subtitleText, state, false) binding?.subtitleText?.setCues( listOf( @@ -337,23 +354,6 @@ class SubtitlesFragment : DialogFragment() { 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) - } - private lateinit var state: SaveCaptionStyle private var hide: Boolean = true @@ -364,30 +364,35 @@ class SubtitlesFragment : DialogFragment() { override fun onStart() { super.onStart() - dialog?.window?.setWindowAnimations(R.style.DialogFullscreen) + dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) } override fun getTheme(): Int { - return R.style.DialogFullscreen + return R.style.DialogFullscreenPlayer } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + var systemBarsAddPadding = isLayout(TV or EMULATOR) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = systemBarsAddPadding || isLandscape(), + padLeft = systemBarsAddPadding + ) + } + + override fun onBindingCreated(binding: SubtitleSettingsBinding) { hide = arguments?.getBoolean("hide") ?: true val popFragment = arguments?.getBoolean("popFragment") ?: false onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - binding?.subsImportText?.text = getString(R.string.subs_import_text).format( + binding.subsImportText.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) - fixPaddingStatusbar(binding?.subsRoot) - state = getCurrentSavedStyle() context?.updateState() val isTvTrueSettings = isLayout(TV) - fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings } @@ -411,7 +416,7 @@ class SubtitlesFragment : DialogFragment() { return@setOnLongClickListener true } } - binding?.apply { + binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) @@ -427,7 +432,7 @@ class SubtitlesFragment : DialogFragment() { // tbh this should not be a dialog if it has so many values val elevationTypes = listOf( 0 to textView.context.getString(R.string.none) - ) + (1..30).map { x -> + ) + (1..40).map { x -> val i = x * 10 i to "${i}dp" } @@ -483,6 +488,33 @@ class SubtitlesFragment : DialogFragment() { return@setOnLongClickListener true } + subsSubtitleAlignment.setFocusableInTv() + subsSubtitleAlignment.setOnClickListener { textView -> + val alignmentTypes = listOf( + null to R.string.automatic, + CustomDecoder.SSA_ALIGNMENT_BOTTOM_LEFT to R.string.bottom_left, + CustomDecoder.SSA_ALIGNMENT_BOTTOM_CENTER to R.string.bottom_center, + CustomDecoder.SSA_ALIGNMENT_BOTTOM_RIGHT to R.string.bottom_right, + CustomDecoder.SSA_ALIGNMENT_MIDDLE_LEFT to R.string.middle_left, + CustomDecoder.SSA_ALIGNMENT_MIDDLE_CENTER to R.string.middle_center, + CustomDecoder.SSA_ALIGNMENT_MIDDLE_RIGHT to R.string.middle_right, + CustomDecoder.SSA_ALIGNMENT_TOP_LEFT to R.string.top_left, + CustomDecoder.SSA_ALIGNMENT_TOP_CENTER to R.string.top_center, + CustomDecoder.SSA_ALIGNMENT_TOP_RIGHT to R.string.top_right, + ) + + activity?.showDialog( + alignmentTypes.map { textView.context.getString(it.second) }, + alignmentTypes.map { it.first }.indexOf(state.alignment), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.alignment = alignmentTypes.map { it.first }[index] + textView.context.updateState() + } + } + subsEdgeType.setFocusableInTv() subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( @@ -606,10 +638,9 @@ class SubtitlesFragment : DialogFragment() { subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit() - .putBoolean(getString(R.string.filter_sub_lang_key), b) - .apply() + PreferenceManager.getDefaultSharedPreferences(ctx).edit { + putBoolean(getString(R.string.filter_sub_lang_key), b) + } } } @@ -671,28 +702,29 @@ class SubtitlesFragment : DialogFragment() { 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) + val languagesTagName = + listOf( + Pair( + textView.context.getString(R.string.none), + textView.context.getString(R.string.none) + ) + ) + + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + + val (langTagsIETF, langNames) = languagesTagName.unzip() - val lang639_1 = langMap.map { it.ISO_639_1 } activity?.showDialog( - langMap.map { it.languageName }, - lang639_1.indexOf(getAutoSelectLanguageISO639_1()), + langNames, + langTagsIETF.indexOf(getAutoSelectLanguageTagIETF()), (textView as TextView).text.toString(), true, dismissCallback ) { index -> - setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) + setKey(SUBTITLE_AUTO_SELECT_KEY, langTagsIETF[index]) } } @@ -704,18 +736,26 @@ class SubtitlesFragment : DialogFragment() { 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 } + val languagesTagName = + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + + val (langTagsIETF, langNames) = languagesTagName.unzip() + + val selectedLanguages = getDownloadSubsLanguageTagIETF() + .map { langTagsIETF.indexOf(it) } + .filter { it >= 0 } activity?.showMultiDialog( - langMap.map { it.languageName }, - keyMap, + langNames, + selectedLanguages, (textView as TextView).text.toString(), dismissCallback ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[it] }.toList()) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt deleted file mode 100644 index 820a01f9f..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.util.Log -import androidx.annotation.StringRes -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import java.lang.Long.min - -object EpisodeSkip { - private const val TAG = "EpisodeSkip" - - enum class SkipType(@StringRes name: Int) { - Opening(R.string.skip_type_op), - Ending(R.string.skip_type_ed), - Recap(R.string.skip_type_recap), - MixedOpening(R.string.skip_type_mixed_op), - MixedEnding(R.string.skip_type_mixed_ed), - Credits(R.string.skip_type_creddits), - Intro(R.string.skip_type_creddits), - } - - data class SkipStamp( - val type: SkipType, - val skipToNextEpisode: Boolean, - val startMs: Long, - val endMs: Long, - ) { - val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( - R.string.skip_type_format, - txt(type.name) - ) - } - - private val cachedStamps = HashMap>() - - private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { - return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh - } - - suspend fun getStamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - hasNextEpisode: Boolean, - ): List { - cachedStamps[episode.id]?.let { list -> - return list - } - - val out = mutableListOf() - Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") - - if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { - data.getMalId()?.toIntOrNull()?.let { malId -> - val (resultLength, stamps) = AniSkip.getResult( - malId, - episode.episode, - episodeDurationMs - ) ?: return@let null - // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work - val dur = min(episodeDurationMs, resultLength) - stamps.mapNotNull { stamp -> - val skipType = when (stamp.skipType) { - "op" -> SkipType.Opening - "ed" -> SkipType.Ending - "recap" -> SkipType.Recap - "mixed-ed" -> SkipType.MixedEnding - "mixed-op" -> SkipType.MixedOpening - else -> null - } ?: return@mapNotNull null - val end = (stamp.interval.endTime * 1000.0).toLong() - val start = (stamp.interval.startTime * 1000.0).toLong() - SkipStamp( - type = skipType, - skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( - end, - dur - ), - startMs = start, - endMs = end - ) - }.let { list -> - out.addAll(list) - } - } - } - if (out.isNotEmpty()) - cachedStamps[episode.id] = out - return out - } -} - -// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt -// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md -object AniSkip { - private const val TAG = "AniSkip" - suspend fun getResult( - malId: Int, - episodeNumber: Int, - episodeLength: Long - ): Pair>? { - return try { - val url = - "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" - Log.i(TAG, "Requesting $url") - - val a = app.get(url) - val res = a.parsed() - Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") - if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null - } catch (t: Throwable) { - Log.i(TAG, "error = ${t.message}") - logError(t) - null - } - } - - data class AniSkipResponse( - @JsonSerialize val found: Boolean, - @JsonSerialize val results: List?, - @JsonSerialize val message: String?, - @JsonSerialize val statusCode: Int - ) - - data class Stamp( - @JsonSerialize val interval: AniSkipInterval, - @JsonSerialize val skipType: String, - @JsonSerialize val skipId: String, - @JsonSerialize val episodeLength: Double - ) - - data class AniSkipInterval( - @JsonSerialize val startTime: Double, - @JsonSerialize val endTime: Double - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index a451972f7..7278fcdd7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -6,7 +6,6 @@ import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.app.NotificationChannel import android.app.NotificationManager -import android.content.ContentValues import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -19,13 +18,9 @@ import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities -import android.net.Uri import android.os.Build -import android.os.Environment import android.os.Handler import android.os.Looper -import android.os.ParcelFileDescriptor -import android.provider.MediaStore import android.text.Spanned import android.util.Log import android.view.View @@ -37,6 +32,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned import androidx.core.widget.ContentLoadingProgressBar @@ -44,7 +40,6 @@ 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.PreviewChannelHelper @@ -59,8 +54,8 @@ 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.APIHolder.apis -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus @@ -90,25 +85,18 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache import java.io.File -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.io.InputStream -import java.io.OutputStream import java.net.URL import java.net.URLDecoder import java.util.concurrent.Executor import java.util.concurrent.Executors -object AppContextUtils { - fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { - for (i in 0..maxViewTypeId) - recycledViewPool.setMaxRecycledViews(i, maxPoolSize) - } +object AppContextUtils { fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? @@ -159,12 +147,12 @@ object AppContextUtils { text.toSpanned() } } - + /** Get channel ID by name */ @SuppressLint("RestrictedApi") private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: VideoDownloadHelper.ResumeWatching? + resumeWatching: DownloadObjects.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -182,10 +170,10 @@ object AppContextUtils { ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) - .setPosterArtUri(Uri.parse(card.posterUrl)) - .setIntentUri(Uri.parse(card.id?.let { + .setPosterArtUri(card.posterUrl?.toUri()) + .setIntentUri((card.id?.let { "$APP_STRING_RESUME_WATCHING://$it" - } ?: card.url)) + } ?: card.url).toUri()) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( resumeWatching?.updateTime ?: System.currentTimeMillis() @@ -331,7 +319,7 @@ object AppContextUtils { val context = this continueWatchingLock.withLock { // A way to get all last watched timestamps - val timeStampHashMap = HashMap() + val timeStampHashMap = HashMap() getAllResumeStateIds()?.forEach { id -> val lastWatched = getLastWatched(id) ?: return@forEach timeStampHashMap[lastWatched.parentId] = lastWatched @@ -381,28 +369,10 @@ object AppContextUtils { } 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 + hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }.map { it.name }) return hashSet } @@ -461,6 +431,14 @@ object AppContextUtils { return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true) } + fun Context.shouldShowPlayerMetadata(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return prefs.getBoolean( + getString(R.string.show_player_metadata_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 @@ -485,9 +463,7 @@ object AppContextUtils { } ?: 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) } - } + val allApis = apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } return if (currentPrefMedia.isEmpty()) { allApis } else { @@ -559,45 +535,6 @@ object AppContextUtils { } } - abstract class DiffAdapter( - open val items: MutableList, - val comparison: (first: T, second: T) -> Boolean = { first, second -> - first.hashCode() == second.hashCode() - } - ) : - RecyclerView.Adapter() { - override fun getItemCount(): Int { - return items.size - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - GenericDiffCallback(this.items, newList) - ) - - items.clear() - items.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - inner class GenericDiffCallback( - private val oldList: List, - private val newList: List - ) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - comparison(oldList[oldItemPosition], newList[newItemPosition]) - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] - } - } - fun Activity.addRepositoryDialog( repositoryName: String, repositoryURL: String, @@ -654,7 +591,7 @@ object AppContextUtils { ) = (this.getActivity() ?: activity)?.runOnUiThread { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(url) + intent.data = url.toUri() intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing @@ -738,6 +675,18 @@ object AppContextUtils { return "" } + fun Context.getShortSeasonText(episode: Int?, season: Int?): String? { + val rEpisode = if (episode == 0) null else episode + val rSeason = if (season == 0) null else season + val seasonNameShort = getString(R.string.season_short) + val episodeNameShort = getString(R.string.episode_short) + return if (rEpisode != null && rSeason != null) { + "$seasonNameShort${rSeason}:$episodeNameShort${rEpisode}" + } else if (rEpisode != null) { + "$episodeNameShort$rEpisode" + }else null + } + fun Activity?.loadCache() { try { cacheClass("android.net.NetworkCapabilities".load()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt index c816dcb04..10736e13e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -2,23 +2,46 @@ package com.lagradost.cloudstream3.utils import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import java.lang.ref.WeakReference import java.util.WeakHashMap object BackPressedCallbackHelper { - private val backPressedCallbacks = WeakHashMap>() - fun ComponentActivity.attachBackPressedCallback(id: String, callback: () -> Unit) { - val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } + private val backPressedCallbacks = + WeakHashMap>() - if (callbackMap.containsKey(id)) return - - val newCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - callback.invoke() + class CallbackHelper( + private val activityRef: WeakReference, + private val callback: OnBackPressedCallback + ) { + fun runDefault() { + val activity = activityRef.get() ?: return + val wasEnabled = callback.isEnabled + callback.isEnabled = false + try { + activity.onBackPressedDispatcher.onBackPressed() + } finally { + callback.isEnabled = wasEnabled } } - callbackMap[id] = newCallback + } + fun ComponentActivity.attachBackPressedCallback( + id: String, + callback: CallbackHelper.() -> Unit + ) { + val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } + if (callbackMap.containsKey(id)) return + + // We use WeakReference to protect against potential leaks. + val activityRef = WeakReference(this) + val newCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + CallbackHelper(activityRef, this).callback() + } + } + + callbackMap[id] = newCallback onBackPressedDispatcher.addCallback(this, newCallback) } @@ -32,9 +55,8 @@ object BackPressedCallbackHelper { fun ComponentActivity.detachBackPressedCallback(id: String) { val callbackMap = backPressedCallbacks[this] ?: return - callbackMap[id]?.let { callback -> - callback.isEnabled = false + callback.remove() callbackMap.remove(id) } @@ -42,4 +64,4 @@ object BackPressedCallbackHelper { backPressedCallbacks.remove(this) } } -} \ 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 3e003c7ea..62426197e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.utils -import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.widget.Toast @@ -11,8 +10,7 @@ import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.readValue -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -21,16 +19,21 @@ import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson 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.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.VideoDownloadManager.StreamData -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly @@ -40,6 +43,7 @@ import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat import java.util.Date +import java.util.Locale object BackupUtils { @@ -49,6 +53,7 @@ object BackupUtils { private val nonTransferableKeys = listOf( ANILIST_CACHED_LIST, MAL_CACHED_LIST, + KITSU_CACHED_LIST, // The plugins themselves are not backed up PLUGINS_KEY, @@ -57,6 +62,7 @@ object BackupUtils { AccountManager.ACCOUNT_TOKEN, AccountManager.ACCOUNT_IDS, + // TODO proper getter for string res keys to ensure that they are updated "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key @@ -77,6 +83,31 @@ object BackupUtils { "open_subtitles_user", "subdl_user", "simkl_token", + + + // Downloads can not be restored from backups. + // The download path URI can not be transferred. + // In the future we may potentially write metadata to files in the download directory + // and make it possible to restore download folders using that metadata. + DOWNLOAD_EPISODE_CACHE_BACKUP, + DOWNLOAD_EPISODE_CACHE, + + // Download headers are unintuitively used in the resume watching system. + // We can therefore not prune download headers in backups. + //DOWNLOAD_HEADER_CACHE_BACKUP, + //DOWNLOAD_HEADER_CACHE, + + + // This may overwrite valid local data with invalid data + KEY_DOWNLOAD_INFO, + + // Prevent backups from automatically starting downloads + KEY_RESUME_IN_QUEUE, + KEY_RESUME_PACKAGES, + QUEUE_KEY, + + // Prevent automatic plugin download after restoring backup + "auto_download_plugins_key2" ) /** false if key should not be contained in backup */ @@ -102,9 +133,7 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context?): BackupFile? { - if (context == null) return null - + private fun getBackup(context: Context): BackupFile { val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } @@ -157,9 +186,13 @@ object BackupUtils { context.restoreMap(backupFile.datastore.long) context.restoreMap(backupFile.datastore.stringSet) } + + // Make sure the library is fresh + for(api in AccountManager.syncApis) { + api.requireLibraryRefresh = true + } } - @SuppressLint("SimpleDateFormat") fun backup(context: Context?) = ioSafe { if (context == null) return@ioSafe @@ -172,14 +205,14 @@ object BackupUtils { return@ioSafe } - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) val displayName = "CS3_Backup_${date}" val backupFile = getBackup(context) val stream = setupBackupStream(context, displayName) fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(mapper.writeValueAsString(backupFile)) + printStream.print(backupFile.toJson()) showToast( R.string.backup_success, @@ -202,7 +235,7 @@ object BackupUtils { } @Throws(IOException::class) - private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): StreamData { + private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), @@ -224,8 +257,8 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val restoredValue = - mapper.readValue(input) + val text = input.bufferedReader().readText() + val restoredValue = parseJson(text) restore( activity, @@ -284,7 +317,7 @@ object BackupUtils { } /** - * Copy of [VideoDownloadManager.basePathToFile], [VideoDownloadManager.getDefaultDir] and [VideoDownloadManager.getBasePath] + * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] * modded for backup specific paths * */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt index 1d9cf5f46..bce8f09dc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.app.Activity import android.app.KeyguardManager import android.content.Context @@ -100,31 +101,51 @@ object BiometricAuthenticator { } private fun isBiometricHardWareAvailable(): Boolean { - // authentication occurs only when this is true and device is truly capable + // 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 + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA -> { + @SuppressLint("RestrictedApi") + 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_NOT_ENABLED_FOR_APPS -> 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 + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + @Suppress("SwitchIntDef") + 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", "SwitchIntDef") + 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 + } } } 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 d83731658..b48c8d40a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.utils -import android.net.Uri +import androidx.core.net.toUri import androidx.media3.common.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastSession @@ -41,7 +41,7 @@ object CastHelper { val srcPoster = epData.poster ?: holder.poster if (srcPoster != null) { - movieMetadata.addImage(WebImage(Uri.parse(srcPoster))) + movieMetadata.addImage(WebImage(srcPoster.toUri())) } var subIndex = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt new file mode 100644 index 000000000..def41d7a0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt @@ -0,0 +1,47 @@ +package com.lagradost.cloudstream3.utils + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import com.lagradost.cloudstream3.mvvm.Resource + +/** + * This is an atomic LiveData where you can do .value instantly after doing .postValue. + * + * The default behavior is a footgun that will cause race conditions, + * as we do not really care if it is posted as we only want the latest data (even in the binding). + * + * Fuck all that is LiveData, because we want this value to be accessible everywhere instantly. + * */ +open class ConsistentLiveData(initValue : T? = null) : LiveData(initValue) { + @Volatile private var internalValue : T? = initValue + + override fun getValue(): T? { + return internalValue + } + + /** If someone want the old behavior then good for them */ + val postedValue : T? get() = super.getValue() + + public override fun postValue(value : T?) { + super.postValue(value) + internalValue = value + } + + @MainThread + public override fun setValue(value: T?) { + super.setValue(value) + internalValue = value + } +} + +/** Atomic resource livedata, to make it easier to work with resources without local copies */ +class ResourceLiveData(initValue : Resource? = null) : ConsistentLiveData>(initValue) { + var success + get() = when(val output = this.value) { + is Resource.Success -> { + output.value + } + else -> null + } + set(value) = this.postValue(value?.let { Resource.Success(it) } ) +} 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 b5192aae2..02ee69791 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,25 +2,27 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit 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.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral import kotlin.reflect.KClass import kotlin.reflect.KProperty +/** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" +const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" +const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" - const val PREFERENCES_NAME = "rebuild_preference" // TODO degelgate by value for get & set @@ -29,6 +31,7 @@ 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 @@ -52,10 +55,10 @@ class PreferenceDelegate( /** 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 + val editor: SharedPreferences.Editor ) { /** Always remember to call apply after */ - fun setKeyRaw(path: String, value: T) { + fun setKeyRaw(path: String, value: T) { @Suppress("UNCHECKED_CAST") if (isStringSet(value)) { editor.putStringSet(path, value as Set) @@ -70,7 +73,7 @@ data class Editor( } } - private fun isStringSet(value: Any?) : Boolean { + private fun isStringSet(value: Any?): Boolean { if (value is Set<*>) { return value.filterIsInstance().size == value.size } @@ -84,8 +87,18 @@ data class Editor( } object DataStore { - val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + // Extensions shouldn't have really been using this version of it, but it seems + // some have. Since there has always been a very easy alternative, we won't + // need to deprecate it that long, and should be able to fully remove it + // once extensions at least use the other version. + @Deprecated( + "Please do not use the mapper version from DataStore. Preferably use methods from AppUtils " + + "to parse JSON. However, you can use the stable-API version of the mapper at " + + "com.lagradost.cloudstream3.mapper to access the mapper directly if necessary.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith("com.lagradost.cloudstream3.mapper"), + ) + val mapper = com.lagradost.cloudstream3.mapper private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -99,9 +112,10 @@ object DataStore { return "${folder}/${path}" } - fun editor(context : Context, isEditingAppSettings: Boolean = false) : Editor { + fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { val editor: SharedPreferences.Editor = - if (isEditingAppSettings) context.getDefaultSharedPrefs().edit() else context.getSharedPrefs().edit() + if (isEditingAppSettings) context.getDefaultSharedPrefs() + .edit() else context.getSharedPrefs().edit() return Editor(editor) } @@ -110,7 +124,9 @@ object DataStore { } fun Context.getKeys(folder: String): List { - return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } + // Ensure that the folder ends with "/" to prevent matching with other folders + val fixedFolder = folder.trimEnd('/') + "/" + return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) } } fun Context.removeKey(folder: String, path: String) { @@ -130,9 +146,9 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { - val editor: SharedPreferences.Editor = prefs.edit() - editor.remove(path) - editor.apply() + prefs.edit { + remove(path) + } } } catch (e: Exception) { logError(e) @@ -141,26 +157,33 @@ object DataStore { fun Context.removeKeys(folder: String): Int { val keys = getKeys("$folder/") - keys.forEach { value -> - removeKey(value) + try { + getSharedPrefs().edit { + keys.forEach { value -> + remove(value) + } + } + return keys.size + } catch (e: Exception) { + logError(e) + return 0 } - return keys.size } fun Context.setKey(path: String, value: T) { try { - val editor: SharedPreferences.Editor = getSharedPrefs().edit() - editor.putString(path, mapper.writeValueAsString(value)) - editor.apply() + getSharedPrefs().edit { + putString(path, value?.toJsonLiteral()) + } } catch (e: Exception) { logError(e) } } - fun Context.getKey(path: String, valueType: Class): T? { + fun Context.getKey(path: String, valueType: Class): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return null - return json.toKotlinObject(valueType) + return parseJson(json, valueType.kotlin) } catch (e: Exception) { return null } @@ -171,11 +194,11 @@ object DataStore { } inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) + return parseJson(this) } - fun String.toKotlinObject(valueType: Class): T { - return mapper.readValue(this, valueType) + fun String.toKotlinObject(valueType: Class): T { + return parseJson(this, valueType.kotlin) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -199,4 +222,4 @@ object DataStore { inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { return getKey(getFolderName(folder, path), defVal) ?: defVal } -} \ No newline at end of file +} 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 bacd416e7..19caead21 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -3,13 +3,14 @@ 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.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.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.EpisodeResponse @@ -23,9 +24,13 @@ 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.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE import com.lagradost.cloudstream3.ui.result.EpisodeSortType +import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import java.util.Calendar import java.util.Date import java.util.GregorianCalendar @@ -53,7 +58,7 @@ class UserPreferenceDelegate( 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 + getKeyClass(realKey, klass.java) ?: default operator fun setValue( self: Any?, @@ -63,7 +68,7 @@ class UserPreferenceDelegate( if (t == null) { removeKey(realKey) } else { - AcraApplication.setKeyClass(realKey, t) + setKeyClass(realKey, t) } } } @@ -272,6 +277,7 @@ object DataStoreHelper { var rating: Int? = null set(value) { if (value != null) { + @Suppress("DEPRECATION_ERROR") score = Score.fromOld(value) } } @@ -524,7 +530,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - VideoDownloadHelper.ResumeWatching( + DownloadObjects.ResumeWatching( parentId, episodeId, episode, @@ -545,7 +551,7 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } - fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { + fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", @@ -553,7 +559,7 @@ object DataStoreHelper { ) } - private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", @@ -642,6 +648,62 @@ object DataStoreHelper { setKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), PosDur(pos, dur)) } + /** Sets the position, duration, and resume data of an episode/movie, + * + * if nextEpisode is not specified it will not be able to set the next episode as resumable if progress > NEXT_WATCH_EPISODE_PERCENTAGE + * */ + fun setViewPosAndResume(id: Int?, position: Long, duration: Long, currentEpisode: Any?, nextEpisode: Any?) { + setViewPos(id, position, duration) + if (id != null) { + when (val meta = currentEpisode) { + is ResultEpisode -> { + if (meta.videoWatchState == VideoWatchState.Watched) { + setVideoWatchState(id, VideoWatchState.None) + } + } + } + } + + val percentage = position * 100L / duration + val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE + val resumeMeta = if (nextEp) nextEpisode else currentEpisode + if (resumeMeta == null && nextEp) { + // remove last watched as it is the last episode and you have watched too much + when (val newMeta = currentEpisode) { + is ResultEpisode -> { + removeLastWatched(newMeta.parentId) + } + + is ExtractorUri -> { + removeLastWatched(newMeta.parentId) + } + } + } else { + // save resume + when (resumeMeta) { + is ResultEpisode -> { + setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = false + ) + } + + is ExtractorUri -> { + setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = true + ) + } + } + } + } + fun getViewPos(id: Int?): PosDur? { if (id == null) return null return getKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), null) @@ -664,7 +726,7 @@ object DataStoreHelper { } fun getDub(id: Int): DubStatus? { - return DubStatus.values() + return DubStatus.entries .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } @@ -716,7 +778,8 @@ object DataStoreHelper { getKey("${idPrefix}_sync", id.toString()) } } - var pinnedProviders:Array + + var pinnedProviders: Array get() = getKey(USER_PINNED_PROVIDERS) ?: emptyArray() set(value) = setKey(USER_PINNED_PROVIDERS, value) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt deleted file mode 100644 index 4eeb4e5da..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.app.Notification -import android.content.Context -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.WorkerParameters -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE -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" - -class DownloadFileWorkManager(val context: Context, private val workerParams: WorkerParameters) : - CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result { - val key = workerParams.inputData.getString("key") - try { - if (key == DOWNLOAD_CHECK) { - downloadCheck(applicationContext, ::handleNotification) - } else if (key != null) { - val info = - applicationContext.getKey(WORK_KEY_INFO, key) - val pkg = - 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 - ) - } - } else if (pkg != null) { - downloadFromResume(applicationContext, pkg, ::handleNotification) - } - removeKeys(key) - } - return Result.success() - } catch (e: Exception) { - logError(e) - if (key != null) { - removeKeys(key) - } - return Result.failure() - } - } - - private fun removeKeys(key: String) { - removeKey(WORK_KEY_INFO, key) - removeKey(WORK_KEY_PACKAGE, key) - } - - private suspend fun awaitDownload(id: Int) { - var isDone = false - val listener = { (localId, localType): Pair -> - if (id == localId) { - when (localType) { - VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { - isDone = true - } - - else -> Unit - } - } - } - downloadStatusEvent += listener - while (!isDone) { - println("AWAITING $id") - delay(1000) - } - downloadStatusEvent -= listener - } - - private fun handleNotification(id: Int, notification: Notification) { - main { - if (SDK_INT >= 29) - setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) - else setForegroundAsync(ForegroundInfo(id, notification)) - - } - } -} \ No newline at end of file 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 a0dfe734e..f66da4e5f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -24,3 +24,28 @@ class Event { } } } + +class EmptyEvent { + private val observers = mutableSetOf() + + val size: Int get() = observers.size + + operator fun plusAssign(observer: Runnable) { + synchronized(observers) { + observers.add(observer) + } + } + + operator fun minusAssign(observer: Runnable) { + synchronized(observers) { + observers.remove(observer) + } + } + + operator fun invoke() { + synchronized(observers) { + for (observer in observers) + observer.run() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt index 14d1b0556..8456094d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,112 +1,166 @@ package com.lagradost.cloudstream3.utils -import com.lagradost.cloudstream3.app +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.utils.Coroutines.main -import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import java.io.InputStream +import kotlin.let object FillerEpisodeCheck { - private const val MAIN_URL = "https://www.animefillerlist.com" - - var list: HashMap? = null - var cache: HashMap> = hashMapOf() - - private fun fixName(name: String): String { - return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") - .replace("[^a-zA-Z0-9 ]".toRegex(), "") - } - - private suspend fun getFillerList(): Boolean { - if (list != null) return true - try { - val result = app.get("$MAIN_URL/shows").text - val documented = Jsoup.parse(result) - val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") - val localList = HashMap() - for (i in localHTMLList) { - val name = i.text() - - if (name.lowercase(Locale.ROOT).contains("manga only")) continue - - val href = i.attr("href") - if (name.isNullOrEmpty() || href.isNullOrEmpty()) { - continue - } - - val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups - if (values != null) { - for (index in 1 until values.size) { - val localName = values[index]?.value ?: continue - localList[fixName(localName)] = href - } - } else { - localList[fixName(name)] = href - } - } - if (localList.size > 0) { - list = localList - return true - } - } catch (e: Exception) { - e.printStackTrace() - } - return false - } - fun String?.toClassDir(): String { val q = this ?: "null" val z = (6..10).random().calc() return q + "cache" + z } - suspend fun getFillerEpisodes(query: String): HashMap? { - try { - cache[query]?.let { - return it - } - if (!getFillerList()) return null - val localList = list ?: return null + data class Show( + @JsonProperty("slug") + val slug: String, + @JsonProperty("title") + val title: String, + @JsonProperty("filler") + val filler: ArrayList, + @JsonProperty("mixedCanon") + val mixedCanon: ArrayList, + @JsonProperty("mangaCanon") + val mangaCanon: ArrayList, + @JsonProperty("animeCanon") + val animeCanon: ArrayList, + ) - // Strips these from the name - val blackList = listOf( - "TV Dubbed", - "(Dub)", - "Subbed", - "(TV)", - "(Uncensored)", - "(Censored)", - "(\\d+)" // year - ) - val blackListRegex = - Regex( - """ (${ - blackList.joinToString(separator = "|").replace("(", "\\(") - .replace(")", "\\)") - })""" - ) + data class MappingRoot( + @JsonProperty("type") + val type: String?, + @JsonProperty("anidb_id") + val anidbId: Long?, + @JsonProperty("anilist_id") + val anilistId: Long?, + @JsonProperty("animecountdown_id") + val animecountdownId: Long?, + @JsonProperty("animenewsnetwork_id") + val animenewsnetworkId: Long?, + @JsonProperty("anime-planet_id") + val animePlanetId: String?, + @JsonProperty("anisearch_id") + val anisearchId: Long?, + @JsonProperty("imdb_id") + val imdbId: String?, + @JsonProperty("kitsu_id") + val kitsuId: Long?, + @JsonProperty("livechart_id") + val livechartId: Long?, + @JsonProperty("mal_id") + val malId: Long?, + @JsonProperty("simkl_id") + val simklId: Long?, + @JsonProperty("themoviedb_id") + val themoviedbId: Long?, + @JsonProperty("tvdb_id") + val tvdbId: Long?, + @JsonProperty("season") + val season: Season?, + ) - val realQuery = - fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") - if (!localList.containsKey(realQuery)) return null - val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE - val result = app.get("$MAIN_URL$href").text - val documented = Jsoup.parse(result) ?: return null - val hashMap = HashMap() - documented.select("table.EpisodeList > tbody > tr").forEach { - val type = it.selectFirst("td.Type > span")?.text() == "Filler" - val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull() - if (episodeNumber != null) { - hashMap[episodeNumber] = type - } - } - cache[query] = hashMap - return hashMap - } catch (e: Exception) { - e.printStackTrace() + data class Season( + @JsonProperty("tvdb") + val tvdb: Long?, + @JsonProperty("tmdb") + val tmdb: Long?, + ) + + data class CombinedMedia( + @JsonProperty("mapping") + val mapping: MappingRoot?, + @JsonProperty("show") + val show: Show + ) + + data class Database( + val mal: HashMap = hashMapOf(), + val anilist: HashMap = hashMapOf(), + val kitsu: HashMap = hashMapOf(), + val tmdb: HashMap = hashMapOf(), + val imdb: HashMap = hashMapOf(), + val name: HashMap = hashMapOf(), + ) + + private var database: Database? = null + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String): String = + name.replace(strip, "").lowercase() + + + @Synchronized + @Throws + @WorkerThread + fun loadJson(): Database { + database?.let { + return it + } + + /** The entire "database" is stored as a json file we can parse */ + val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! + val text = stream.reader().readText() + + val allMedia = parseJson>(text) + val pending = Database() + for (media in allMedia) { + val lowercase = stripName(media.show.title) + pending.name[lowercase] = media + val map = media.mapping ?: continue + + map.imdbId?.let { id -> pending.imdb[id] = media } + map.malId?.let { id -> pending.mal[id] = media } + map.anilistId?.let { id -> pending.anilist[id] = media } + map.kitsuId?.let { id -> pending.kitsu[id] = media } + map.season?.tmdb?.let { id -> pending.tmdb[id] = media } + } + database = pending + return pending + } + + val loadCache: HashMap?> = hashMapOf() + + @Synchronized + @Throws + @WorkerThread + fun getFillerEpisodes(data: LoadResponse): HashSet? { + /** Only for anime */ + if (data.type != TvType.Anime) { return null } + /** Try to hit the cache for this entry, to avoid recreating the hashset */ + loadCache[data.getId()]?.let { cachedResponse -> + return cachedResponse + } + val db = loadJson() + + val media = + db.mal[data.getMalId()?.toLongOrNull()] + ?: db.anilist[data.getAniListId()?.toLongOrNull()] + ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] + ?: db.imdb[data.getImdbId()] + ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] + ?: db.name[stripName(data.name)] + + return media?.show?.filler?.toHashSet().also { response -> + loadCache[data.getId()] = response + } } private fun Int.calc(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt new file mode 100644 index 000000000..58ff44bb2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt @@ -0,0 +1,20 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context + +/** + * Simple helper to get the short commit hash from assets. + * The hash is generated at build and stored as an asset + * that can be accessed at runtime for Gradle + * configuration cache support. + */ +object GitInfo { + fun Context.currentCommitHash(): String = try { + assets.open("git-hash.txt") + .bufferedReader() + .readText() + .trim() + } catch (_: Exception) { + "" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt index a75d1b437..96193fe45 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.utils import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log import android.widget.ImageView @@ -11,7 +12,9 @@ import coil3.EventListener import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader +import coil3.decode.BitmapFactoryDecoder import coil3.disk.DiskCache +import coil3.dispose import coil3.load import coil3.memory.MemoryCache import coil3.network.NetworkHeaders @@ -21,76 +24,86 @@ import coil3.request.CachePolicy import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.allowHardware +import coil3.request.bitmapConfig import coil3.request.crossfade import coil3.util.DebugLogger import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.network.buildDefaultClient -import okhttp3.HttpUrl import okio.Path.Companion.toOkioPath import java.io.File import java.nio.ByteBuffer object ImageLoader { - private const val TAG = "CoilImgLoader" - - internal fun buildImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + internal fun buildImageLoader(context: PlatformContext): ImageLoader { + val isBrokenHardware = hasPotentialBrokenHardware() + return ImageLoader.Builder(context) .crossfade(200) - .allowHardware(SDK_INT >= 28) // SDK_INT >= 28, cant use hardware bitmaps for Palette Builder + .allowHardware(SDK_INT >= 28 && !isBrokenHardware) .diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .memoryCache { - MemoryCache.Builder().maxSizePercent(context, 0.1) // Use 10 % of the app's available memory for caching + MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache + .strongReferencesEnabled(false) .build() } .diskCache { DiskCache.Builder() .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) .maxSizeBytes(512L * 1024 * 1024) // 512 MB - .maxSizePercent(0.04) // Use 4 % of the device's storage space for disk caching + .maxSizePercent(0.04) // max 4% of storage for disk caching .build() } /** Pass interceptors with care, unnecessary passing tokens to servers or image hosting services causes unauthorized exceptions **/ - .components { add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) } - .also { - it.setupCoilLogger() - Log.d(TAG, "buildImageLoader: Setting COIL Image Loader.") + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) + if (isBrokenHardware) { + add(BitmapFactoryDecoder.Factory()) + } // sw decoder + } + .apply { + if (isBrokenHardware) { // coil will auto choose optimal config on modern device + bitmapConfig(Bitmap.Config.ARGB_8888) + } + setupCoilLogger() } .build() + } - /** Use DebugLogger on debug builds which won't slow down release builds & use EventListener for + /** DebugLogger on debug builds which won't slow down release builds & use EventListener for Errors on release builds. **/ internal fun ImageLoader.Builder.setupCoilLogger() { if (BuildConfig.DEBUG) { logger(DebugLogger()) - Log.d(TAG, "setupCoilLogger: Activated DEBUG_LOGGER FOR COIL") } else { eventListener(object : EventListener() { override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) - Log.e(TAG, "Error loading image: ${result.throwable}") + Log.e(TAG, "Image load error: ${result.throwable.message ?: result.throwable}") + Log.e(TAG, " URL: ${request.data}") + Log.e(TAG, " allowHardware: ${request.allowHardware}") + Log.e(TAG, " hardware: ${Build.HARDWARE}, board: ${Build.BOARD}") } }) - Log.d(TAG, "setupCoilLogger: Activated EVENT_LISTENER FOR COIL") } } - /** we use coil's built in loader with our global synchronized instance, this way we achieve - latest and complete functionality as well as stability **/ + /** coil's built in loader attached w/ global synchronized instance **/ private fun ImageView.loadImageInternal( imageData: Any?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations ) { - // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) - this.load(null) - - if(imageData == null) return // Just in case - - // Use Coil's built-in load method but with our custom module & a decent USER-AGENT always - // which can be overridden by extensions. + // clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column) + this.dispose() + if (imageData == null) return + // setImageResource is better than coil3 on resources due to attr + if (imageData is Int) { + this.setImageResource(imageData); return + } + // headers can be overridden by extensions. this.load(imageData, SingletonImageLoader.get(context)) { this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> headerBuilder["User-Agent"] = USER_AGENT @@ -98,11 +111,22 @@ object ImageLoader { headerBuilder[key] = value } }.build()) - builder() // if passed } } + private fun hasPotentialBrokenHardware(): Boolean { + val hardware = Build.HARDWARE?.lowercase() ?: "" + val board = Build.BOARD?.lowercase() ?: "" + val model = Build.MODEL?.lowercase() ?: "" + val manufacturer = Build.MANUFACTURER?.lowercase() ?: "" + val allwinnerPatterns = listOf("sun50iw9", "h713", "allwinner", "sunxi") + val problematicModels = + listOf("hy320", "hy300", "a10plus", "magcubic", "sinoy", "android tv box") + return allwinnerPatterns.any { it in hardware || it in board || it in manufacturer } || + problematicModels.any { it in model } + } + /** TYPE_SAFE_LOADERS **/ fun ImageView.loadImage( imageData: UiImage?, @@ -131,12 +155,6 @@ object ImageLoader { builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) - fun ImageView.loadImage( - imageData: HttpUrl?, - headers: Map? = null, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) - fun ImageView.loadImage( imageData: File?, builder: ImageRequest.Builder.() -> Unit = {} @@ -166,4 +184,4 @@ object ImageLoader { imageData: ByteBuffer?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt index 1f90b920d..6ed4d4afa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt @@ -7,9 +7,9 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap import coil3.Image import coil3.asImage -import coil3.request.ImageRequest /// Type safe any image, because THIS IS NOT PYTHON sealed class UiImage { @@ -30,11 +30,7 @@ fun drawableToBitmap(drawable: Drawable): Bitmap? { return when (drawable) { is BitmapDrawable -> drawable.bitmap else -> { - val bitmap = Bitmap.createBitmap( - drawable.intrinsicWidth, - drawable.intrinsicHeight, - Bitmap.Config.ARGB_8888 - ) + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) 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 8bce8f639..b01f6e07e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,392 +3,363 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.services.PackageInstallerService +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink 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.services.PackageInstallerService -import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import java.io.BufferedReader +import java.io.File import java.io.IOException import java.io.InputStreamReader +object InAppUpdater { + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" -class InAppUpdater { - companion object { - private const val GITHUB_USER_NAME = "recloudstream" - private const val GITHUB_REPO = "cloudstream" + private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" + private const val LOG_TAG = "InAppUpdater" - private const val LOG_TAG = "InAppUpdater" + private data class GithubAsset( + @JsonProperty("name") val name: String, + @JsonProperty("size") val size: Int, // Size in bytes + @JsonProperty("browser_download_url") val browserDownloadUrl: String, + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive + ) - // === 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 - ) + private data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("body") val body: String, // Description + @JsonProperty("assets") val assets: List, + @JsonProperty("target_commitish") val targetCommitish: String, // Branch + @JsonProperty("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") val nodeId: String, + ) - data class GithubRelease( - @JsonProperty("tag_name") val tagName: String, // Version code - @JsonProperty("body") val body: String, // Desc - @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val targetCommitish: String, // branch - @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val nodeId: String //Node Id - ) + private data class GithubObject( + @JsonProperty("sha") val sha: String, // SHA-256 hash + @JsonProperty("type") val type: String, + @JsonProperty("url") val url: String, + ) - data class GithubObject( - @JsonProperty("sha") val sha: String, // sha 256 hash - @JsonProperty("type") val type: String, // object type - @JsonProperty("url") val url: String, - ) + private data class GithubTag( + @JsonProperty("object") val githubObject: GithubObject, + ) - data class GithubTag( - @JsonProperty("object") val githubObject: GithubObject, - ) + private data class Update( + @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, + @JsonProperty("updateURL") val updateURL: String?, + @JsonProperty("updateVersion") val updateVersion: String?, + @JsonProperty("changelog") val changelog: String?, + @JsonProperty("updateNodeId") val updateNodeId: String?, + ) - data class Update( - @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, - @JsonProperty("updateURL") val updateURL: String?, - @JsonProperty("updateVersion") val updateVersion: String?, - @JsonProperty("changelog") val changelog: String?, - @JsonProperty("updateNodeId") val updateNodeId: String? - ) - - private suspend fun Activity.getAppUpdate(): Update { - return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - if (settingsManager.getBoolean( - getString(R.string.prerelease_update_key), - resources.getBoolean(R.bool.is_prerelease) - ) - ) { - getPreReleaseUpdate() - } else { - getReleaseUpdate() - } - } catch (e: Exception) { - Log.e(LOG_TAG, Log.getStackTraceString(e)) - Update(false, null, null, null, null) + private suspend fun Activity.getAppUpdate(installPrerelease: Boolean): Update { + return try { + when { + // No updates on debug version + BuildConfig.DEBUG -> Update(false, null, null, null, null) + BuildConfig.FLAVOR == "prerelease" || installPrerelease -> getPreReleaseUpdate() + else -> getReleaseUpdate() } + } catch (e: Exception) { + Log.e(LOG_TAG, Log.getStackTraceString(e)) + Update(false, null, null, null, null) } + } - private suspend fun Activity.getReleaseUpdate(): Update { - val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = - parseJson>( - app.get( - url, - headers = headers - ).text - ) + private suspend fun Activity.getReleaseUpdate(): Update { + val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = parseJson>( + app.get(url, headers = headers).text + ).toList() - val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") - val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") - /* - val releases = response.map { it.assets }.flatten() - .filter { it.content_type == "application/vnd.android.package-archive" } - val found = - releases.sortedWith(compareBy { - versionRegex.find(it.name)?.groupValues?.get(2) - }).toList().lastOrNull()*/ - val foundList = - response.filter { rel -> - !rel.prerelease - }.sortedWith(compareBy { release -> - release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> - versionRegex.find( - it1 - )?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - } - }).toList() - val found = foundList.lastOrNull() - val foundAsset = found?.assets?.getOrNull(0) - val currentVersion = packageName?.let { - packageManager.getPackageInfo( - it, - 0 - ) - } - - foundAsset?.name?.let { assetName -> - val foundVersion = versionRegex.find(assetName) - val shouldUpdate = - if (foundAsset.browserDownloadUrl != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> - versionRegexLocal.find(versionName)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - }?.compareTo( - foundVersion.groupValues.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - )!! < 0 else false - return if (foundVersion != null) { - Update( - shouldUpdate, - foundAsset.browserDownloadUrl, - foundVersion.groupValues[2], - found.body, - found.nodeId - ) - } else { - Update(false, null, null, null, null) + val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") + val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") + val foundList = response.filter { rel -> + !rel.prerelease + }.sortedWith(compareBy { release -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> + versionRegex.find(it1)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } + }).toList() + + val found = foundList.lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) + val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } + + if (foundVersion == null) { return Update(false, null, null, null, null) } - private suspend fun Activity.getPreReleaseUpdate(): Update { - val tagUrl = - "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" - val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = - parseJson>(app.get(releaseUrl, headers = headers).text) - - val found = - response.lastOrNull { rel -> - rel.prerelease || rel.tagName == "pre-release" - } - val foundAsset = found?.assets?.filter { it -> - it.contentType == "application/vnd.android.package-archive" - }?.getOrNull(0) - - val tagResponse = - parseJson(app.get(tagUrl, headers = headers).text) - - Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.githubObject.sha.take(7)}") - - val shouldUpdate = - (getString(R.string.commit_hash) - .trim { c -> c.isWhitespace() } - .take(7) - != - tagResponse.githubObject.sha - .trim { c -> c.isWhitespace() } - .take(7)) - - return if (foundAsset != null) { - Update( - shouldUpdate, - foundAsset.browserDownloadUrl, - tagResponse.githubObject.sha.take(10), - found.body, - found.nodeId - ) - } else { - Update(false, null, null, null, null) - } + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) } - - private val updateLock = Mutex() - - private suspend fun Activity.downloadUpdate(url: String): Boolean { - try { - Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" - val appUpdateSuffix = "apk" - - // Delete all old updates - this.cacheDir.listFiles()?.filter { - it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix - }?.forEach { - deleteFileOnExit(it) + val shouldUpdate = if (foundAsset.browserDownloadUrl.isBlank()) { + false + } else { + currentVersion?.versionName?.let { versionName -> + versionRegexLocal.find(versionName)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } - - val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") - val sink: BufferedSink = downloadedFile.sink().buffer() - - updateLock.withLock { - sink.writeAll(app.get(url).body.source()) - sink.close() - openApk(this, Uri.fromFile(downloadedFile)) - } - return true - } catch (e: Exception) { - return false - } + }?.compareTo( + foundVersion.groupValues.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + })!! < 0 } - private fun openApk(context: Context, uri: Uri) { - try { - uri.path?.let { - val contentUri = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + ".provider", - File(it) - ) - val installIntent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - data = contentUri - } - context.startActivity(installIntent) - } - } catch (e: Exception) { - logError(e) - } + return Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + foundVersion.groupValues[2], + found.body, + found.nodeId + ) + } + + private suspend fun Activity.getPreReleaseUpdate(): Update { + val tagUrl = + "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" + val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = parseJson>( + app.get(releaseUrl, headers = headers).text + ).toList() + + val found = response.lastOrNull { rel -> + rel.prerelease || rel.tagName == "pre-release" } - /** - * @param checkAutoUpdate if the update check was launched automatically - **/ - suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val foundAsset = found?.assets?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) - if (!checkAutoUpdate || settingsManager.getBoolean( - getString(R.string.auto_update_key), - true - ) - ) { - val update = getAppUpdate() - if ( - update.shouldUpdate && - update.updateURL != null) { + if (foundAsset == null) { + return Update(false, null, null, null, null) + } - // Check if update should be skipped - val updateNodeId = - settingsManager.getString(getString(R.string.skip_update_key), "") + val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) + val updateCommitHash = tagResponse.githubObject.sha.trim().take(7) + Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") - // Skips the update if its an automatic update and the update is skipped - // This allows updating manually - if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { - return false - } + return Update( + currentCommitHash() != updateCommitHash, + foundAsset.browserDownloadUrl, + updateCommitHash, + found.body, + found.nodeId + ) + } - runOnUiThread { - try { - val currentVersion = packageName?.let { - packageManager.getPackageInfo( - it, - 0 - ) - } + private val updateLock = Mutex() - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, - update.updateVersion - ) - ) + private suspend fun Activity.downloadUpdate(url: String): Boolean { + try { + Log.d(LOG_TAG, "Downloading update: $url") + val appUpdateName = "CloudStream" + val appUpdateSuffix = "apk" - val logRegex = Regex("\\[(.*?)\\]\\((.*?)\\)") - val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> - matchResult.groupValues[1] - } // Sanitized because it looks cluttered + // Delete all old updates + this.cacheDir.listFiles()?.filter { + it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix + }?.forEach { deleteFileOnExit(it) } - builder.setMessage(sanitizedChangelog) + val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") + val sink: BufferedSink = downloadedFile.sink().buffer() - val context = this - builder.apply { - setPositiveButton(R.string.update) { _, _ -> - // Forcefully start any delayed installations - if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton - - showToast(R.string.download_started, Toast.LENGTH_LONG) - - // Check if the setting hasn't been changed - if (settingsManager.getInt( - getString(R.string.apk_installer_key), - -1 - ) == -1 - ) { - if (isMiUi()) // Set to legacy if using miui - settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), 1) - .apply() - } - - val currentInstaller = - settingsManager.getInt( - getString(R.string.apk_installer_key), - 0 - ) - - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.Companion.getIntent( - context, - update.updateURL - ) - ContextCompat.startForegroundService(context, intent) - } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) - runOnUiThread { - showToast( - R.string.download_failed, - Toast.LENGTH_LONG - ) - } - } - } - } - } - - setNegativeButton(R.string.cancel) { _, _ -> } - - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit().putString( - getString(R.string.skip_update_key), - update.updateNodeId ?: "" - ).apply() - } - } - } - builder.show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - } - } - return true - } - return false + updateLock.withLock { + sink.writeAll(app.get(url).body.source()) + sink.close() + openApk(this, Uri.fromFile(downloadedFile)) } + + return true + } catch (e: Exception) { + logError(e) + return false + } + } + + private fun openApk(context: Context, uri: Uri) = safe { + val path = uri.path ?: return@safe + val contentUri = FileProvider.getUriForFile( + context, BuildConfig.APPLICATION_ID + ".provider", File(path) + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } + + fun Activity.installPreReleaseIfNeeded() = ioSafe { + val isInstalled = try { + packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) + true + } catch (_: NameNotFoundException) { + false + } + + if (isInstalled) { + showToast(R.string.prerelease_already_installed) + } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { + showToast(R.string.prerelease_install_failed) + } + } + + + /** + * @param checkAutoUpdate if the update check was launched automatically + * @param installPrerelease if we want to install the pre-release version + */ + suspend fun Activity.runAutoUpdate( + checkAutoUpdate: Boolean = true, installPrerelease: Boolean = false + ): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val autoUpdateEnabled = + settingsManager.getBoolean(getString(R.string.auto_update_key), true) + if (checkAutoUpdate && !autoUpdateEnabled) { return false } - private fun isMiUi(): Boolean { - return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) + val update = getAppUpdate(installPrerelease) + if (!update.shouldUpdate || update.updateURL == null) { + return false } - private fun getSystemProperty(propName: String): String? { - return try { - val p = Runtime.getRuntime().exec("getprop $propName") - BufferedReader(InputStreamReader(p.inputStream), 1024).use { - it.readLine() + // Check if update should be skipped + val updateNodeId = settingsManager.getString( + getString(R.string.skip_update_key), "" + ) + + // Skips the update if its an automatic update and the update is skipped + // This allows updating manually + if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { + return false + } + + runOnUiThread { + safe { + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) } - } catch (ex: IOException) { - null + + val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) + builder.setTitle( + getString(R.string.new_update_format).format( + currentVersion?.versionName, update.updateVersion + ) + ) + + val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") + val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> + matchResult.groupValues[1] + } // Sanitized because it looks cluttered + + builder.setMessage(sanitizedChangelog) + builder.apply { + setPositiveButton(R.string.update) { _, _ -> + // Forcefully start any delayed installations + if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + + showToast(R.string.download_started, Toast.LENGTH_LONG) + + // Check if the setting hasn't been changed + if (settingsManager.getInt( + getString(R.string.apk_installer_key), -1 + ) == -1 + ) { + // Set to legacy installer if using MIUI + if (isMiUi()) { + settingsManager.edit { + putInt(getString(R.string.apk_installer_key), 1) + } + } + } + + val currentInstaller = settingsManager.getInt( + getString(R.string.apk_installer_key), 1 + ) + + when (currentInstaller) { + // New method + 0 -> { + val intent = PackageInstallerService.Companion.getIntent( + this@runAutoUpdate, update.updateURL + ) + ContextCompat.startForegroundService( + this@runAutoUpdate, intent + ) + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) { + runOnUiThread { + showToast( + R.string.download_failed, Toast.LENGTH_LONG + ) + } + } + } + } + } + } + + setNegativeButton(R.string.cancel) { _, _ -> } + + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit { + putString( + getString(R.string.skip_update_key), update.updateNodeId ?: "" + ) + } + } + } + } + builder.show().setDefaultFocus() } } + return true + } + + private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() + + private fun getSystemProperty(propName: String): String? = try { + val p = Runtime.getRuntime().exec("getprop $propName") + BufferedReader(InputStreamReader(p.inputStream), 1024).use { + it.readLine() + } + } catch (_: IOException) { + null } } 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 4be0dd56c..67851f629 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -11,7 +11,7 @@ import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import android.widget.Toast -import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.PackageInstallerService diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt index 1e572fb7c..6580182bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -4,16 +4,67 @@ import android.content.Context import android.graphics.Matrix import android.graphics.drawable.Drawable import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +/** + * A custom [AppCompatImageView] that allows precise control over the visible crop area + * of an image by adjusting its horizontal and vertical center offset percentages. + * + * ### Key Features: + * - Allows **manual vertical or horizontal cropping** via percentage offsets. + * - Works seamlessly with Coil, Glide, or any image loading library. + * + * ### Usage (XML): + * You can set the crop offset directly in XML using custom attributes: + * ```xml + * + * ``` + * - `app:cropYCenterOffsetPct` → controls how far vertically the image shifts + * `0.0` = top-aligned, `0.5` = centered, `1.0` = bottom-aligned. + * - `app:cropXCenterOffsetPct` → controls how far horizontally the image shifts + * `0.0` = left, `0.5` = center, `1.0` = right. + * + * ### Programmatic Example: + * ```kotlin + * imageView.cropYCenterOffsetPct = 0.15f // Show slightly more (15%) of the top area + * imageView.cropXCenterOffsetPct = 0.5f // Keep image centered horizontally + * imageView.redraw() //Only needed if you changed cropYCenterOffsetPct/cropXCenterOffsetPct at runtime + * ``` + * + * ### Notes: + * - Must use `android:scaleType="matrix"` to enable manual matrix transformations. + * - Reference: https://stackoverflow.com/a/29055283 + * + * @property cropYCenterOffsetPct the vertical crop percentage (0.0–1.0) + * @property cropXCenterOffsetPct the horizontal crop percentage (0.0–1.0) + * + * @see ImageView.ScaleType.MATRIX + */ 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?) : super(context!!, attrs) { + initAttrs(context, attrs) + } + constructor( context: Context?, attrs: AttributeSet?, defStyle: Int - ) : super(context!!, attrs, defStyle) + ) : super(context!!, attrs, defStyle) { + initAttrs(context, attrs) + } var cropYCenterOffsetPct: Float get() = mCropYCenterOffsetPct!! @@ -43,12 +94,12 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { var dy = 0f if (dWidth * vHeight > vWidth * dHeight) { val cropXCenterOffsetPct = - if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!!.toFloat() else 0.5f + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f scale = vHeight.toFloat() / dHeight.toFloat() dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct } else { val cropYCenterOffsetPct = - if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!!.toFloat() else 0f + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f scale = vWidth.toFloat() / dWidth.toFloat() dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct } @@ -80,6 +131,7 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { 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(); @@ -91,4 +143,26 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { setImageDrawable(d) } } + + private fun initAttrs(context: Context, attrs: AttributeSet?) { + attrs ?: return + context.withStyledAttributes(attrs, R.styleable.PercentageCropImageView) { + try { + if (hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { + mCropYCenterOffsetPct = getFloat( + R.styleable.PercentageCropImageView_cropYCenterOffsetPct, + 0.5f + ) + } + if (hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { + mCropXCenterOffsetPct = getFloat( + R.styleable.PercentageCropImageView_cropXCenterOffsetPct, + 0.5f + ) + } + } catch (e: Exception) { + logError(e) + } + } + } } \ 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 index 0d7a8abc4..e3c7d68df 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -3,12 +3,13 @@ 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.VERSION.SDK_INT import android.os.PowerManager import android.provider.Settings import android.util.Log import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import androidx.core.net.toUri import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast @@ -38,7 +39,6 @@ object BatteryOptimizationChecker { fun Context.showBatteryOptimizationDialog() { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - try { AlertDialog.Builder(this) .setTitle(R.string.battery_dialog_title) @@ -46,9 +46,9 @@ object BatteryOptimizationChecker { .setMessage(R.string.battery_dialog_message) .setPositiveButton(R.string.ok) { _, _ -> showRequestIgnoreBatteryOptDialog() } .setNegativeButton(R.string.cancel) { _, _ -> - settingsManager.edit() - .putBoolean(getString(R.string.battery_optimisation_key), false) - .apply() + settingsManager.edit { + putBoolean(getString(R.string.battery_optimisation_key), false) + } } .show() } catch (t: Throwable) { @@ -67,7 +67,7 @@ object BatteryOptimizationChecker { try { val intent = Intent().apply { action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = Uri.parse("package:$PACKAGE_NAME") + data = "package:$PACKAGE_NAME".toUri() } startActivity(intent) } catch (t: Throwable) { 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 ea75ff62e..26c710103 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -22,6 +22,7 @@ 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.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage @@ -113,8 +114,12 @@ object SingleSelectionHelper { val textView = binding.text1 val applyButton = binding.applyBtt val cancelButton = binding.cancelBtt - val applyHolder = - binding.applyBttHolder + val applyHolder = binding.applyBttHolder + + if (isLayout(PHONE or EMULATOR) && dialog is BottomSheetDialog) { + binding.dragHandle.isVisible = true + listView.isNestedScrollingEnabled = true + } applyHolder.isVisible = realShowApply if (!realShowApply) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index 66a6e156c..c0068f91a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -2,8 +2,8 @@ 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 +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SubtitleUtils { @@ -13,17 +13,21 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { - val relative = info.relativePath - val display = info.displayName - val cleanDisplay = cleanDisplayName(display) + fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { + val cleanDisplay = cleanDisplayName(info.displayName) - getFolder(context, relative, info.basePath)?.forEach { (name, uri) -> - if (isMatchingSubtitle(name, display, cleanDisplay)) { - val subtitleFile = SafeFile.fromUri(context, uri) - if (subtitleFile == null || subtitleFile.delete() != true) { - Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}") - } + val base = basePathToFile(context, info.basePath) + val folder = + base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return + val folderFiles = folder.listFiles() ?: return + + for (file in folderFiles) { + val name = file.name() ?: continue + if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) { + continue + } + if (file.delete() != true) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name") } } } @@ -39,7 +43,7 @@ object SubtitleUtils { cleanDisplay: String ): Boolean { // Check if the file has a valid subtitle extension - val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } + val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } // We can't have the exact same file as a subtitle val isNotDisplayName = !name.equals(display, ignoreCase = true) @@ -53,4 +57,4 @@ object SubtitleUtils { 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 351e77c8d..6e74fa00a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import java.util.concurrent.TimeUnit object SyncUtil { @@ -71,7 +71,7 @@ object SyncUtil { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text - val mapped = parseJson(response) + val mapped = tryParseJson(response) val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id @@ -96,10 +96,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 @@ -169,4 +167,4 @@ object SyncUtil { @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index 049f92fb4..8c50afee7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -3,10 +3,10 @@ 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) @@ -48,6 +48,10 @@ object TestingUtils { messageLog.add(Message(LogLevel.Error, message)) } } + + private fun fail(message: String): Nothing = throw AssertionError(message) + private fun assertTrue(message: String, condition: Boolean) { if (!condition) fail(message) } + private fun assertNotNull(message: String, value: Any?) { if (value == null) fail(message) } class TestResultList(val results: List) : TestResult(true) class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true) @@ -87,7 +91,7 @@ object TestingUtils { } catch (e: Throwable) { when (e) { is NotImplementedError -> { - Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") + fail("Provider marked as hasMainPage, while in reality is has not been implemented") } is CancellationException -> { @@ -112,10 +116,10 @@ object TestingUtils { val searchResults = testQueries.firstNotNullOfOrNull { query -> try { logger.log("Searching for: $query") - api.search(query).takeIf { !it.isNullOrEmpty() } + api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } } catch (e: Throwable) { if (e is NotImplementedError) { - Assert.fail("Provider has not implemented search()") + fail("Provider has not implemented search()") } else if (e is CancellationException) { throw e } @@ -125,7 +129,7 @@ object TestingUtils { } return if (searchResults.isNullOrEmpty()) { - Assert.fail("Api ${api.name} did not return any search responses") + fail("Api ${api.name} did not return any search responses") TestResult.Fail // Should not be reached } else { TestResultList(searchResults) @@ -216,7 +220,7 @@ object TestingUtils { // return TestResult(validResults) } catch (e: Throwable) { if (e is NotImplementedError) { - Assert.fail("Provider has not implemented load()") + fail("Provider has not implemented load()") } throw e } @@ -228,14 +232,14 @@ object TestingUtils { url: String?, logger: Logger ): TestResult { - Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) + 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( + assertTrue( "Api ${api.name} returns link with invalid url ${link.url}", link.url.length > 4 ) @@ -245,12 +249,12 @@ object TestingUtils { logger.log("Links loaded: $linksLoaded") return TestResult(linksLoaded > 0) } else { - Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") + 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()") + fail("Provider has not implemented loadLinks()") } else -> { @@ -276,7 +280,7 @@ object TestingUtils { // Test Homepage val homepage = testHomepage(api, logger) - Assert.assertTrue("Homepage failed to load", homepage.success) + assertTrue("Homepage failed to load", homepage.success) val homePageList = (homepage as? TestResultList)?.results ?: emptyList() // Test Search Results @@ -287,7 +291,7 @@ object TestingUtils { listOf("over", "iron", "guy")).take(3) val searchResults = testSearch(api, searchQueries, logger) - Assert.assertTrue("Failed to get search results", searchResults.success) + assertTrue("Failed to get search results", searchResults.success) searchResults as TestResultList // Test Load and LoadLinks @@ -321,4 +325,4 @@ object TestingUtils { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt new file mode 100644 index 000000000..feecbe312 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt @@ -0,0 +1,164 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.net.toUri +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.base64Encode +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import java.net.URLEncoder + +const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" + +object TvChannelUtils { + fun Context.saveProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = (existing + programId).distinct() + setKey(PROGRAM_ID_LIST_KEY, updated) + } + fun Context.getStoredProgramIds(): List { + return getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + } + fun Context.removeProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = existing.filter { it != programId } + setKey(PROGRAM_ID_LIST_KEY, updated) + } + + + fun getChannelId(context: Context, channelName: String): Long? { + return try { + context.contentResolver.query( + TvContractCompat.Channels.CONTENT_URI, + arrayOf( + TvContractCompat.Channels._ID, + TvContractCompat.Channels.COLUMN_DISPLAY_NAME + ), + null, + null, + null + )?.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels._ID) + ) + val name = cursor.getString( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) + ) + if (name == channelName) return id + } + null + } + } catch (e: Exception) { + Log.e("TvChannelUtils", "Query failed: ${e.message}", e) + null + } + } + + /** Insert programs into a channel */ + @SuppressLint("RestrictedApi") + fun addPrograms(context: Context, channelId: Long, items: List) { + for (item in items) { + try { + val nameBase64 = base64Encode(item.apiName.toByteArray(Charsets.UTF_8)) + val urlBase64 = base64Encode(item.url.toByteArray(Charsets.UTF_8)) + val csshareUri = "$APP_STRING_SHARE:$nameBase64?$urlBase64" + val poster=item.posterUrl + val builder = PreviewProgram.Builder() + .setChannelId(channelId) + .setTitle(item.name) + .apply { + val scoreText = item.score?.toStringNull(0.1, 10, 1)?.let { + " - " + txt(R.string.rating_format, it).asString(context) + } ?: "" + setDescription("${item.apiName}$scoreText") + } + .setContentId(item.url) + .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) + .setIntentUri(csshareUri.toUri()) + .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3) + + // Validate poster URL before setting + if (!poster.isNullOrBlank() && poster.startsWith("http")) { + builder.setPosterArtUri(poster.toUri()) + + } + val program = builder.build() + + val uri = context.contentResolver.insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + program.toContentValues() + ) + + if (uri != null) { + val programId = ContentUris.parseId(uri) + context.saveProgramId(programId) + Log.d("TvChannelUtils", "Inserted program ${item.name}, ID=$programId") + } else { + Log.e("TvChannelUtils", "Insert failed for ${item.name}") + } + + } catch (error: Exception) { + Log.e("TvChannelUtils", "Error inserting ${item.name}: $error") + } + } + } + + fun deleteStoredPrograms(context: Context) { + val programIds = context.getStoredProgramIds() + + for (id in programIds) { + val uri = ContentUris.withAppendedId(TvContractCompat.PreviewPrograms.CONTENT_URI, id) + try { + val rowsDeleted = context.contentResolver.delete(uri, null, null) + if (rowsDeleted > 0) { + context.removeProgramId(id) // Remove from persistent list + } else { + Log.w("ProgramDelete", "No program found for ID: $id") + } + } catch (e: Exception) { + Log.e("ProgramDelete", "Failed to delete program ID: $id", e) + } + } + + Log.d("ProgramDelete", "Finished deleting stored programs") + } + + fun createTvChannel(context: Context) { + val componentName = ComponentName(context, MainActivity::class.java) + val iconUri = "android.resource://${context.packageName}/mipmap/ic_launcher".toUri() + val inputId = TvContractCompat.buildInputId(componentName) + val channel = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setAppLinkIconUri(iconUri) + .setDisplayName(context.getString(R.string.app_name)) + .setAppLinkIntent(Intent(Intent.ACTION_VIEW).apply { + data = "cloudstreamapp://open".toUri() + }) + .setInputId(inputId) + .build() + + val channelUri = context.contentResolver.insert( + TvContractCompat.Channels.CONTENT_URI, + channel.toContentValues() + ) + + channelUri?.let { + val channelId = ContentUris.parseId(it) + TvContractCompat.requestChannelBrowsable(context, channelId) + Log.d("TvChannelUtils", "Channel Created: $channelId") + } + } + +} \ 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 557bb1ea5..c12674816 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -13,7 +13,12 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.TransactionTooLargeException @@ -23,7 +28,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams -import android.view.WindowInsets import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.ListAdapter @@ -31,6 +35,7 @@ import android.widget.ListView import android.widget.Toast.LENGTH_LONG import androidx.annotation.AttrRes import androidx.annotation.ColorInt +import androidx.annotation.DimenRes import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder @@ -38,6 +43,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import androidx.core.content.withStyledAttributes import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.green @@ -47,6 +53,11 @@ import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.NavOptions @@ -57,17 +68,17 @@ 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 +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.settings.Globals 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.isRtl import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.UIHelper.navigate import kotlinx.coroutines.delay import kotlin.math.roundToInt import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.disableBackPressedCallback @@ -93,7 +104,8 @@ object UIHelper { fun populateChips( view: ChipGroup?, tags: List, - @StyleRes style: Int = R.style.ChipFilled + @StyleRes style: Int = R.style.ChipFilled, + @AttrRes textColor: Int? = R.attr.white, ) { if (view == null) return view.removeAllViews() @@ -114,7 +126,9 @@ object UIHelper { chip.isCheckable = false chip.isFocusable = false chip.isClickable = false - chip.setTextColor(context.colorFromAttribute(R.attr.white)) + textColor?.let { + chip.setTextColor(context.colorFromAttribute(it)) + } view.addView(chip) } } @@ -190,17 +204,15 @@ object UIHelper { listView.requestLayout() } - fun Context?.getSpanCount(): Int? { - val compactView = false - val spanCountLandscape = if (compactView) 2 else 6 - val spanCountPortrait = if (compactView) 1 else 3 - val orientation = this?.resources?.configuration?.orientation ?: return null + fun Context.getSpanCount(isHorizontal:Boolean=false): Int { +// val compactView = false + val spanCountLandscape = if (isHorizontal) 3 else 6 + val spanCountPortrait = if (isHorizontal) 2 else 3 + val orientation = resources.configuration.orientation return if (orientation == Configuration.ORIENTATION_LANDSCAPE) { spanCountLandscape - } else { - spanCountPortrait - } + } else spanCountPortrait } fun Fragment.hideKeyboard() { @@ -211,7 +223,7 @@ object UIHelper { } fun View?.setAppBarNoScrollFlagsOnTV() { - if (isLayout(Globals.TV or EMULATOR)) { + if (isLayout(TV or EMULATOR)) { this?.updateLayoutParams { scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } @@ -247,10 +259,12 @@ object UIHelper { } // Open activities from an activity outside the nav graph - fun Context.openActivity(activity: Class<*>, args: Bundle? = null) { + fun Context.openActivity(activity: Class<*>, args: Bundle? = null, baseIntent: Intent? = null) { val tag = "NavComponent" try { - val intent = Intent(this, activity) + val intent = baseIntent ?: Intent() + intent.setClass(this, activity) + if (args != null) { intent.putExtras(args) } @@ -262,12 +276,12 @@ object UIHelper { } /** If you want to call this from a BackPressedCallback, pass the name of the callback to temporarily disable it */ - fun FragmentActivity.popCurrentPage(fromBackPressedCallback : String? = null) { + fun FragmentActivity.popCurrentPage(fromBackPressedCallback: String? = null) { // Use the main looper handler to post actions on the main thread main { // Post the back press action to the main thread handler to ensure it executes // after any currently pending UI updates or fragment transactions. - if(fromBackPressedCallback != null) { + if (fromBackPressedCallback != null) { disableBackPressedCallback(fromBackPressedCallback) } if (!supportFragmentManager.isStateSaved) { @@ -285,7 +299,7 @@ object UIHelper { onBackPressedDispatcher.onBackPressed() } } - if(fromBackPressedCallback != null) { + if (fromBackPressedCallback != null) { enableBackPressedCallback(fromBackPressedCallback) } } @@ -293,18 +307,25 @@ object UIHelper { @ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val color = typedArray.getColor(0, 0) - typedArray.recycle() + val color = colorFromAttribute(resource) + return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color + } - if (alphaFactor < 1f) { - val alpha = (color.alpha * alphaFactor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + @ColorInt + fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { + var color = 0 + withStyledAttributes(attrs = intArrayOf(attribute)) { + color = getColor(0, 0) } - return color } + @ColorInt + fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (color.alpha * factor).roundToInt() + return Color.argb(alpha, color.red, color.green, color.blue) + } + var createPaletteAsyncCache: HashMap = hashMapOf() fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) { createPaletteAsyncCache[url]?.let { palette -> @@ -319,35 +340,18 @@ object UIHelper { } } - fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { - val alpha = (Color.alpha(color) * factor).roundToInt() - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) - return Color.argb(alpha, red, green, blue) - } - - fun Context.colorFromAttribute(attribute: Int): Int { - val attributes = obtainStyledAttributes(intArrayOf(attribute)) - val color = attributes.getColor(0, 0) - attributes.recycle() - return color - } - fun Activity.hideSystemUI() { // Enables regular immersive mode. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + return + } + // 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 @@ -359,12 +363,25 @@ object UIHelper { // Hide the nav bar and status bar or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN - ) // FIXME this should be replaced - //} + ) + } + + fun Activity.enableEdgeToEdgeCompat() { + // edge-to-edge is very buggy on earlier versions + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + WindowCompat.enableEdgeToEdge(window) + } + + fun Activity.setNavigationBarColorCompat(@AttrRes resourceId: Int) { + // edge-to-edge handles this + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return + + @Suppress("DEPRECATION") + window?.navigationBarColor = colorFromAttribute(resourceId) } fun Context.getStatusBarHeight(): Int { - if (isLayout(Globals.TV or EMULATOR)) { + if (isLayout(TV or EMULATOR)) { return 0 } @@ -376,17 +393,6 @@ object UIHelper { return result } - fun fixPaddingStatusbar(v: View?) { - if (v == null) return - val ctx = v.context ?: return - v.setPadding( - v.paddingLeft, - v.paddingTop + ctx.getStatusBarHeight(), - v.paddingRight, - v.paddingBottom - ) - } - fun fixPaddingStatusbarMargin(v: View?) { if (v == null) return val ctx = v.context ?: return @@ -411,6 +417,84 @@ object UIHelper { v.layoutParams = params } + fun fixSystemBarsPadding( + v: View, + @DimenRes heightResId: Int? = null, + @DimenRes widthResId: Int? = null, + padTop: Boolean = true, + padBottom: Boolean = true, + padLeft: Boolean = true, + padRight: Boolean = true, + overlayCutout: Boolean = true, + fixIme: Boolean = false + ) { + // edge-to-edge is very buggy on earlier versions so we just + // handle the status bar here instead. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (padTop) { + val ctx = v.context ?: return + v.updatePadding(top = ctx.getStatusBarHeight()) + } + return + } + + ViewCompat.setOnApplyWindowInsetsListener(v) { view, windowInsets -> + val leftCheck = if (view.isRtl()) padRight else padLeft + val rightCheck = if (view.isRtl()) padLeft else padRight + + val insetTypes = WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + if (fixIme) WindowInsetsCompat.Type.ime() else 0 + + val insets = windowInsets.getInsets(insetTypes) + + view.updatePadding( + left = if (leftCheck) insets.left else view.paddingLeft, + right = if (rightCheck) insets.right else view.paddingRight, + bottom = if (padBottom) insets.bottom else view.paddingBottom, + top = if (padTop) insets.top else view.paddingTop + ) + + heightResId?.let { + val heightPx = view.resources.getDimensionPixelSize(it) + view.updateLayoutParams { + height = heightPx + insets.bottom + } + } + + widthResId?.let { + val widthPx = view.resources.getDimensionPixelSize(it) + view.updateLayoutParams { + val startInset = if (view.isRtl()) insets.right else insets.left + width = if (startInset > 0) widthPx + startInset else widthPx + } + } + + if (overlayCutout && isLayout(PHONE)) { + // Draw a black overlay over the cutout. We do this so that + // it doesn't use the fragment background. We want it to + // appear as if the screen actually ends at cutout. + val cutout = windowInsets.displayCutout + if (cutout != null) { + val left = if (!leftCheck) 0 else cutout.safeInsetLeft + val right = if (!rightCheck) 0 else cutout.safeInsetRight + view.overlay.clear() + if (left > 0 || right > 0) { + view.overlay.add( + CutoutOverlayDrawable( + view, + leftCutout = left, + rightCutout = right + ) + ) + } + } + } + + WindowInsetsCompat.CONSUMED + } + } + fun Context.getNavigationBarHeight(): Int { var result = 0 val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") @@ -426,12 +510,12 @@ object UIHelper { return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) } - fun Activity.changeStatusBarState(hide: Boolean): Int { + fun Activity.changeStatusBarState(hide: Boolean) { try { if (hide) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.insetsController?.hide(WindowInsets.Type.statusBars()) - + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.hide(WindowInsetsCompat.Type.statusBars()) } else { @Suppress("DEPRECATION") window.setFlags( @@ -439,80 +523,39 @@ object UIHelper { WindowManager.LayoutParams.FLAG_FULLSCREEN ) } - 0 } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.insetsController?.show(WindowInsets.Type.statusBars()) + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.show(WindowInsetsCompat.Type.statusBars()) } else { @Suppress("DEPRECATION") window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } - - this.getStatusBarHeight() } } catch (t: Throwable) { logError(t) } - return if (hide) { - 0 - } else { - this.getStatusBarHeight() - } } // 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() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + if (isLayout(EMULATOR)) { + controller.show(WindowInsetsCompat.Type.navigationBars()) + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else controller.show(WindowInsetsCompat.Type.systemBars()) + return + } - /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - - WindowCompat.setDecorFitsSystemWindows(window, true) - WindowInsetsControllerCompat(window, View(this)).show(WindowInsetsCompat.Type.systemBars()) - - } else {*/ - /** WINDOW COMPAT IS BUGGY DUE TO FU*KED UP PLAYER AND TRAILERS **/ @Suppress("DEPRECATION") window.decorView.systemUiVisibility = - (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) // FIXME this should be replaced - //} + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) changeStatusBarState(isLayout(EMULATOR)) } - fun Context.shouldShowPIPMode(isInPlayer: Boolean): Boolean { - return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - settingsManager?.getBoolean( - getString(R.string.pip_enabled_key), - true - ) ?: true && isInPlayer - } catch (e: Exception) { - logError(e) - false - } - } - - fun Context.hasPIPPermission(): Boolean { - val appOps = - getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - appOps.unsafeCheckOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - android.os.Process.myUid(), - packageName - ) == AppOpsManager.MODE_ALLOWED - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - @Suppress("DEPRECATION") - appOps.checkOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - android.os.Process.myUid(), - packageName - ) == AppOpsManager.MODE_ALLOWED - } else { - return true - } - } - fun hideKeyboard(view: View?) { if (view == null) return @@ -599,4 +642,39 @@ object UIHelper { popup.show() return popup } +} + +private class CutoutOverlayDrawable( + private val view: View, + private val leftCutout: Int, + private val rightCutout: Int, +) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + style = Paint.Style.FILL + } + + override fun draw(canvas: Canvas) { + if (leftCutout > 0) canvas.drawRect( + 0f, + 0f, + leftCutout.toFloat(), + view.height.toFloat(), + paint + ) + if (rightCutout > 0) { + canvas.drawRect( + view.width - rightCutout.toFloat(), + 0f, view.width.toFloat(), + view.height.toFloat(), + paint + ) + } + } + + override fun setAlpha(alpha: Int) {} + override fun setColorFilter(colorFilter: ColorFilter?) {} + + @Suppress("OVERRIDE_DEPRECATION") + override fun getOpacity() = PixelFormat.OPAQUE } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt deleted file mode 100644 index 966ccfd56..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.TvType -object VideoDownloadHelper { - abstract class DownloadCached( - @JsonProperty("id") open val id: Int, - ) - - data class DownloadEpisodeCached( - @JsonProperty("name") val name: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("episode") val episode: Int, - @JsonProperty("season") val season: Int?, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("score") var score: Score? = null, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ): DownloadCached(id) { - @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) - @Deprecated( - "`rating` is the old scoring system, use score instead", - replaceWith = ReplaceWith("score"), - level = DeprecationLevel.ERROR - ) - var rating: Int? = null - set(value) { - if (value != null) { - score = Score.fromOld(value) - } - } - } - - data class DownloadHeaderCached( - @JsonProperty("apiName") val apiName: String, - @JsonProperty("url") val url: String, - @JsonProperty("type") val type: TvType, - @JsonProperty("name") val name: String, - @JsonProperty("poster") val poster: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ): DownloadCached(id) - - data class ResumeWatching( - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("episodeId") val episodeId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt new file mode 100644 index 000000000..898c30a1c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile + +object DownloadFileManagement { + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + internal fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + internal fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + internal 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 + } + + internal fun getFileName( + context: Context, + metadata: DownloadObjects.DownloadEpisodeMetadata + ): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + internal fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + + + internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory( + relativePath, + createMissingDirectories = false + ) + ?.findFile(displayName) + } + + internal fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * 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? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt similarity index 65% rename from app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index 4ca9d0655..7cb190667 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -1,40 +1,30 @@ -package com.lagradost.cloudstream3.utils +package com.lagradost.cloudstream3.utils.downloader + import android.Manifest import android.annotation.SuppressLint import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent -import android.content.* +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.net.Uri import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log +import android.widget.Toast import androidx.annotation.DrawableRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat -import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.preference.PreferenceManager -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import coil3.Extras -import coil3.SingletonImageLoader -import coil3.asDrawable -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import com.fasterxml.jackson.annotation.JsonProperty 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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.IDownloadableMinimum import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -42,14 +32,58 @@ 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.safe import com.lagradost.cloudstream3.services.VideoDownloadService +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD +import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main +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.removeKey +import com.lagradost.cloudstream3.utils.Event +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 com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.safefile.MediaFileContentType +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join +import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException @@ -60,23 +94,24 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.Closeable import java.io.IOException import java.io.OutputStream -import java.util.* const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { - private fun maxConcurrentDownloads(context: Context): Int = + fun maxConcurrentDownloads(context: Context): Int = PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 @@ -84,8 +119,11 @@ object VideoDownloadManager { PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 - private var currentDownloads = mutableListOf() + private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) + val currentDownloads: StateFlow> = _currentDownloads + const val TAG = "VDM" + private const val DOWNLOAD_NOTIFICATION_TAG = "FROM_DOWNLOADER" 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" @@ -129,56 +167,6 @@ object VideoDownloadManager { Stop, } - data class DownloadEpisodeMetadata( - @JsonProperty("id") val id: Int, - @JsonProperty("mainName") val mainName: String, - @JsonProperty("sourceApiName") val sourceApiName: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("name") val name: String?, - @JsonProperty("season") val season: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("type") val type: TvType?, - ) - - data class DownloadItem( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List, - ) - - data class DownloadResumePackage( - @JsonProperty("item") val item: DownloadItem, - @JsonProperty("linkIndex") val linkIndex: Int?, - ) - - data class DownloadedFileInfo( - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("relativePath") val relativePath: String, - @JsonProperty("displayName") val displayName: String, - @JsonProperty("extraInfo") val extraInfo: String? = null, - @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getDefaultPath() - ) - - data class DownloadedFileInfoResult( - @JsonProperty("fileLength") val fileLength: Long, - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("path") val path: Uri, - ) - - data class DownloadQueueResumePackage( - @JsonProperty("index") val index: Int, - @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, - ) /** Invalid input, just skip to the next one as the same args will give the same error */ private val DOWNLOAD_INVALID_INPUT = @@ -195,117 +183,60 @@ object VideoDownloadManager { /** 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) + /** The download only downloaded partial */ + private val DOWNLOAD_PARTIAL_SUCCESS = + DownloadStatus(retrySame = true, tryNext = false, success = true) + + /** 50MB minimum size */ + const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L + /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) - const val KEY_RESUME_PACKAGES = "download_resume" + const val KEY_RESUME_PACKAGES = "download_resume_2" const val KEY_DOWNLOAD_INFO = "download_info" - private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + + /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] + * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. + */ + const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" +// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() - val downloadQueue = LinkedList() +// val downloadQueue = LinkedList() - private var hasCreatedNotChanel = false + private var hasCreatedNotChannel = false private fun Context.createNotificationChannel() { - hasCreatedNotChanel = true - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name) - val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description) - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply { - description = descriptionText + hasCreatedNotChannel = true + + this.createNotificationChannel( + DOWNLOAD_CHANNEL_ID, + DOWNLOAD_CHANNEL_NAME, + DOWNLOAD_CHANNEL_DESCRIPT + ) + } + + fun cancelAllDownloadNotifications(context: Context) { + val manager = NotificationManagerCompat.from(context) + manager.activeNotifications.forEach { notification -> + if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { + manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) } - // Register the channel with the system - val notificationManager: NotificationManager = - this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) } } - ///** 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? { - try { - if (cachedBitmaps.containsKey(url)) { - return cachedBitmaps[url] - } - - val imageLoader = SingletonImageLoader.get(this) - - val request = ImageRequest.Builder(this) - .data(url) - .apply { - headers?.forEach { (key, value) -> - extras[Extras.Key(key)] = value - } - } - .build() - - val bitmap = runBlocking { - val result = imageLoader.execute(request) - (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) - ?.toBitmap() - } - - bitmap?.let { - cachedBitmaps[url] = it - } - - return bitmap - } catch (e: Exception) { - logError(e) - return null - } - } - //calculate the time - private fun getEstimatedTimeLeft(context:Context,bytesPerSecond: Long, progress: Long, total: Long):String{ - if(bytesPerSecond <= 0 ) return "" - val timeInSec = (total - progress)/bytesPerSecond - val hrs = timeInSec/3600 - val mins = (timeInSec%3600)/ 60 - val secs = timeInSec % 60 - val timeFormated:UiText? = when{ - hrs>0 -> txt( - R.string.download_time_left_hour_min_sec_format, - hrs, - mins, - secs - ) - mins>0 -> txt( - R.string.download_time_left_min_sec_format, - mins, - secs - ) - secs>0 -> txt( - R.string.download_time_left_sec_format, - secs - ) - else -> null - } - return timeFormated?.asString(context) ?: "" - } /** * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. * */ @SuppressLint("StringFormatInvalid") - private suspend fun createNotification( + private suspend fun createDownloadNotification( context: Context, source: String?, linkName: String?, @@ -321,7 +252,6 @@ object VideoDownloadManager { try { if (total <= 0) return null// crash, invalid data -// main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) @@ -388,7 +318,7 @@ object VideoDownloadManager { val mbFormat = "%.1f MB" if (hlsProgress != null && hlsTotal != null) { - progressPercentage = hlsProgress.toLong() * 100 / hlsTotal + progressPercentage = hlsProgress * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() suffix = " - $mbFormat".format(progress / 1000000f) @@ -405,9 +335,9 @@ object VideoDownloadManager { } else "" val remainingTime = - if(state == DownloadType.IsDownloading){ - getEstimatedTimeLeft(context,bytesPerSecond, progress, total) - }else "" + if (state == DownloadType.IsDownloading) { + getEstimatedTimeLeft(context, bytesPerSecond, progress, total) + } else "" val bigText = when (state) { @@ -470,7 +400,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -482,6 +412,9 @@ object VideoDownloadManager { actionTypes.add(DownloadActionType.Resume) actionTypes.add(DownloadActionType.Stop) } + if (state == DownloadType.IsPending) { + actionTypes.add(DownloadActionType.Stop) + } // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { @@ -520,7 +453,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChanel) { + if (!hasCreatedNotChannel) { context.createNotificationChannel() } @@ -535,7 +468,7 @@ object VideoDownloadManager { ) { return null } - notify(ep.id, notification) + notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) } return notification } catch (e: Exception) { @@ -544,69 +477,6 @@ object VideoDownloadManager { } } - private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" - fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { - var tempName = name - for (c in RESERVED_CHARS) { - tempName = tempName.replace(c, ' ') - } - if (removeSpaces) tempName = tempName.replace(" ", "") - return tempName.replace(" ", " ").trim(' ') - } - - /** - * Used for getting video player subs. - * @return List of pairs for the files in this format: - * */ - fun getFolder( - context: Context, - relativePath: String, - basePath: String? - ): List>? { - val base = basePathToFile(context, basePath) - val folder = - base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null - - //if (folder.isDirectory() != false) return null - - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } - } - - - data class CreateNotificationMetadata( - val type: DownloadType, - val bytesDownloaded: Long, - 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() == true - } - - 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( @@ -628,7 +498,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. + * Used for initializing downloads and backups. * */ @Throws(IOException::class) fun setupStream( @@ -661,6 +531,7 @@ object VideoDownloadManager { /** This class handles the notifications, as well as the relevant key */ data class DownloadMetaData( private val id: Int?, + private val linkHash : Int, var bytesDownloaded: Long = 0, var bytesWritten: Long = 0, @@ -672,7 +543,7 @@ object VideoDownloadManager { private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, - + val isHLS : Boolean, // how many segments that we have downloaded var hlsProgress: Int = 0, // how many segments that exist @@ -690,13 +561,17 @@ object VideoDownloadManager { lastDownloadedBytes = length } + /** Returns the appropriate failed status based on download progress */ + fun failedStatus() = if (this.bytesWritten > DOWNLOAD_PARTIAL_MIN_SIZE) + DOWNLOAD_PARTIAL_SUCCESS + else + DOWNLOAD_FAILED + 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 */ @@ -717,8 +592,6 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() stopListener?.invoke() stopListener = null } @@ -733,11 +606,32 @@ object VideoDownloadManager { private fun updateFileInfo() { if (id == null) return downloadFileInfoTemplate?.let { template -> + /** This looks strange, but fixes an issue where we do an instant retry, and it fails immediately, + * eg. by turning off wifi */ + val totalBytesValue = if (approxTotalBytes <= bytesDownloaded) { + val prevInfo = getKey( + KEY_DOWNLOAD_INFO, + id.toString() + ) + + /** If this link is the same as the last cached video link metadata */ + if (prevInfo != null && prevInfo.linkHash == linkHash) { + /** Try to use totalBytes if it exists, otherwise the max of the prev data, + * and download size to ensure total >= downloaded */ + totalBytes ?: maxOf(prevInfo.totalBytes, bytesDownloaded) + } else { + approxTotalBytes + } + } else { + approxTotalBytes + } + setKey( KEY_DOWNLOAD_INFO, id.toString(), template.copy( - totalBytes = approxTotalBytes, + linkHash = linkHash, + totalBytes = totalBytesValue, extraInfo = if (isHLS) hlsWrittenProgress.toString() else null ) ) @@ -880,34 +774,12 @@ object VideoDownloadManager { } } - /** 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, + /** This specifies where chunk i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, @@ -932,6 +804,7 @@ object VideoDownloadManager { private suspend fun resolve( startByte: Long, endByte: Long?, + buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Long = withContext(Dispatchers.IO) { var currentByte: Long = startByte @@ -950,7 +823,6 @@ object VideoDownloadManager { ) val requestStream = request.body.byteStream() - val buffer = ByteArray(bufferSize) var read: Int try { @@ -981,6 +853,7 @@ object VideoDownloadManager { suspend fun resolveSafe( index: Int, retries: Int = 3, + buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Boolean { var start = chuckStartByte.getOrNull(index) ?: return false @@ -989,16 +862,16 @@ object VideoDownloadManager { for (i in 0 until retries) { try { // in case - start = resolve(start, end, callback) + start = resolve(start, end, buffer, 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) { + } catch (_: IllegalStateException) { return false - } catch (e: CancellationException) { + } catch (_: CancellationException) { return false - } catch (t: Throwable) { + } catch (_: Throwable) { continue } } @@ -1117,38 +990,6 @@ object VideoDownloadManager { ) } - /** 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( @@ -1176,6 +1017,8 @@ object VideoDownloadManager { bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, + linkHash = link.url.hashCode(), + isHLS = false ) try { // get the file path @@ -1197,14 +1040,7 @@ object VideoDownloadManager { 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", ) ) ) @@ -1323,13 +1159,29 @@ object VideoDownloadManager { } } - // this will take up the first available job and resolve + // Reuse a download buffer to decrease unnecessary alloc + val buffer = ByteArray(items.bufferSize) + + // This will take up the first available job and resolve while (true) { if (!isActive) return@launch + + var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue } // mutex just in case, we never want this to fail due to multithreading @@ -1340,7 +1192,7 @@ object VideoDownloadManager { // in case something has gone wrong set to failed if the fail is not caused by // user cancellation - if (!items.resolveSafe(index, callback = callback)) { + if (!items.resolveSafe(index, buffer = buffer, callback = callback)) { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed @@ -1365,7 +1217,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1395,11 +1247,11 @@ object VideoDownloadManager { throw e } catch (t: Throwable) { // some sort of network error, will error - + logError(t) // 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 + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() @@ -1421,7 +1273,9 @@ object VideoDownloadManager { val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, - id = parentId + id = parentId, + linkHash = link.url.hashCode(), + isHLS = true ) var fileStream: OutputStream? = null try { @@ -1444,6 +1298,7 @@ object VideoDownloadManager { // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt + metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( @@ -1458,8 +1313,6 @@ object VideoDownloadManager { 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() ) @@ -1497,10 +1350,23 @@ object VideoDownloadManager { launch(Dispatchers.IO) { while (true) { if (!isActive) return@launch + + var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue } // mutex just in case, we never want this to fail due to multithreading @@ -1520,50 +1386,45 @@ object VideoDownloadManager { 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 { + fileMutex.withLock { try { - // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling - fileMutex.unlock() + // 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 + } } } } @@ -1583,7 +1444,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1599,7 +1460,7 @@ object VideoDownloadManager { } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() metadata.close() @@ -1610,75 +1471,6 @@ object VideoDownloadManager { return "$name.$extension" } - /** - * Gets the default download path as an UniFile. - * Vital for legacy downloads, be careful about changing anything here. - * - * 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? { - // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads - ) - } - - /** - * 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? { - return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFilePath(context, path) - } - } - - /** - * Base path where downloaded things should be stored, changes depending on settings. - * Returns the file and a string to be stored for future file retrieval. - * UniFile.filePath is not sufficient for storage. - * */ - 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 getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { - return getFileName(context, metadata.name, metadata.episode, metadata.season) - } - - private fun getFileName( - context: Context, - epName: String?, - episode: Int?, - season: Int? - ): String { - // kinda ugly ik - return sanitizeFilename( - if (epName == null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" - } else { - "${context.getString(R.string.episode)} $episode" - } - } else { - if (episode != null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" - } else { - "${context.getString(R.string.episode)} $episode - $epName" - } - } else { - epName - } - } - ) - } - private suspend fun downloadSingleEpisode( context: Context, source: String?, @@ -1704,7 +1496,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createNotification( + createDownloadNotification( context, source, link.name, @@ -1758,100 +1550,17 @@ object VideoDownloadManager { ) } - else -> throw IllegalArgumentException("unsuported download type") + else -> throw IllegalArgumentException("Unsupported download type") } - } catch (t: Throwable) { + } catch (_: Throwable) { return DOWNLOAD_FAILED } finally { extractorJob.cancel() } } - suspend fun downloadCheck( - context: Context, notificationCallback: (Int, Notification) -> Unit, - ) { - if (!(currentDownloads.size < maxConcurrentDownloads(context) && downloadQueue.size > 0)) return - val pkg = downloadQueue.removeAt(0) - val item = pkg.item - val id = item.ep.id - if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(id to DownloadActionType.Resume) - return - } - - currentDownloads.add(id) - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - - if (connectionResult.retrySame) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult.success) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { - downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) - break - } - } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the work manager - downloadCheckUsingWorker(context) - } - - // return id - } - - /* 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, - createMissingDirectories = false - ) - ?.findFile(displayName) - } - - private fun getDownloadFileInfo( + fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { @@ -1861,8 +1570,7 @@ object VideoDownloadManager { val file = info.toFile(context) // 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 + if (file == null || file.exists() == false) { return null } @@ -1913,35 +1621,20 @@ object VideoDownloadManager { 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) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(id to DownloadType.IsStopped) - downloadDeleteEvent.invoke(id) - val isFileDeleted = file?.delete() == true || file?.exists() == false - if (isFileDeleted) deleteMatchingSubtitles(context, info) + + if (isFileDeleted) { + deleteMatchingSubtitles(context, info) + downloadEvent.invoke(id to DownloadActionType.Stop) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) + } return isFileDeleted } @@ -1950,119 +1643,453 @@ object VideoDownloadManager { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - suspend fun downloadFromResume( - context: Context, - pkg: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - setKey: Boolean = true + fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { + return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + } + + fun getDownloadEpisodeMetadata( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadEpisodeMetadata { + return DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + class EpisodeDownloadInstance( + val context: Context, + val downloadQueueWrapper: DownloadQueueWrapper ) { - if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { - downloadQueue.addLast(pkg) - downloadCheck(context, notificationCallback) - if (setKey) saveQueue() - //ret - } else { - downloadEvent( - pkg.item.ep.id to DownloadActionType.Resume - ) - //null - } - } + private val TAG = "EpisodeDownloadInstance" + private var subtitleDownloadJob: Job? = null + private var downloadJob: Job? = null + private var linkLoadingJob: Job? = null - private fun saveQueue() { - try { - val dQueue = - downloadQueue.toList() - .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } - .toTypedArray() - setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) - } catch (t: Throwable) { - logError(t) - } - } + /** isCompleted just means the download should not be retried. + * It includes stopped by user AND completion of file download. + * */ + var isCompleted = false + set(value) { + field = value + if (value) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + // Do not emit events when completed as it may also trigger on cancellation. - /*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean { - val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? - for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { - if (serviceClass.name == service.service.className) { - return true + + // Force refresh the queue when completed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() + } + } + + /** Cancels all active jobs and sets instance to failed. */ + fun cancelDownload() { + val cause = "Cancel call from cancelDownload" + this.subtitleDownloadJob?.cancel(cause) + this.linkLoadingJob?.cancel(cause) + + // Should not cancel the download job, it may need to clean up itself. + // Better to send a status event using isStopped and let it cancel itself. + isCancelled = true + } + + // Run to cancel ongoing work, delete partial work and refresh queue + private fun cleanup(status: DownloadType) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + val id = downloadQueueWrapper.id + + // Delete subtitles on cancel + safe { + val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) + if (info != null) { + deleteMatchingSubtitles(context, info) + } + } + + downloadStatusEvent.invoke(Pair(id, status)) + downloadStatus[id] = status + downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) + + // Force refresh the queue when failed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() + } + + var isCancelled = false + set(value) { + val oldField = field + field = value + + // Clean up cancelled work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsStopped) + } + } + + + /** This failure can be both downloader and user initiated. + * Do not automatically retry in case of failure. */ + var isFailed = false + set(value) { + val oldField = field + field = value + + // Clean up failed work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsFailed) + } + } + + companion object { + private fun displayNotification(context: Context, id: Int, notification: Notification) { + safe { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return@safe + + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) + } } } - return false - }*/ - suspend fun downloadEpisode( - context: Context?, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - notificationCallback: (Int, Notification) -> Unit, - ) { - if (context == null) return - if (links.isEmpty()) return - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) - } + private suspend fun downloadFromResume( + downloadResumePackage: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + ) { + val item = downloadResumePackage.item + val id = item.ep.id + if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } - /** Worker stuff */ - private fun startWork(context: Context, key: String) { - val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java) - .setInputData( - Data.Builder() - .putString("key", key) - .build() + _currentDownloads.update { downloads -> + downloads + id + } + + try { + for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = downloadResumePackage.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 + isCompleted = true + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + isFailed = true + break + } + } + } catch (e: Exception) { + isFailed = true + logError(e) + } finally { + isFailed = !isCompleted + _currentDownloads.update { downloads -> + downloads - id + } + } + } + + private suspend fun startDownload( + info: DownloadItem?, + pkg: DownloadResumePackage? + ) { + try { + if (info != null) { + getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> + downloadFromResume(dpkg) { id, notification -> + displayNotification(context, id, notification) + } + } ?: run { + if (info.links.isEmpty()) return + downloadFromResume( + DownloadResumePackage(info, null) + ) { id, notification -> + displayNotification(context, id, notification) + } + } + } else if (pkg != null) { + downloadFromResume(pkg) { id, notification -> + displayNotification(context, id, notification) + } + } + return + } catch (e: Exception) { + isFailed = true + logError(e) + return + } + } + + private suspend fun downloadFromResume() { + val resumePackage = downloadQueueWrapper.resumePackage ?: return + downloadFromResume(resumePackage) { id, notification -> + displayNotification(context, id, notification) + } + } + + fun startDownload() { + Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") + setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) + + ioSafe { + if (downloadQueueWrapper.resumePackage != null) { + downloadFromResume() + // Load links if they are not already loaded + } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { + downloadEpisodeWithoutLinks() + } else if (downloadQueueWrapper.downloadItem?.links != null) { + downloadEpisodeWithLinks( + sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), + downloadQueueWrapper.downloadItem.subs + ) + } + } + } + + private fun downloadEpisodeWithLinks( + links: List, + subs: List? + ) { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + try { + // Prepare visual keys + setKey( + DOWNLOAD_HEADER_CACHE, + downloadItem.resultId.toString(), + DownloadObjects.DownloadHeaderCached( + apiName = downloadItem.apiName, + url = downloadItem.resultUrl, + type = downloadItem.resultType, + name = downloadItem.resultName, + poster = downloadItem.resultPoster, + id = downloadItem.resultId, + cacheTime = System.currentTimeMillis(), + ) + ) + setKey( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + downloadItem.resultId.toString() + ), // 3 deep folder for faster access + downloadItem.episode.id.toString(), + DownloadObjects.DownloadEpisodeCached( + name = downloadItem.episode.name, + poster = downloadItem.episode.poster, + episode = downloadItem.episode.episode, + season = downloadItem.episode.season, + id = downloadItem.episode.id, + parentId = downloadItem.resultId, + score = downloadItem.episode.score, + description = downloadItem.episode.description, + cacheTime = System.currentTimeMillis(), + ) + ) + + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + val folder = + getFolder(downloadItem.resultType, downloadItem.resultName) + val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" + + // DOWNLOAD VIDEO + val info = DownloadItem(src, folder, meta, links) + + this.downloadJob = ioSafe { + startDownload(info, null) + } + + // 1. Checks if the lang should be downloaded + // 2. Makes it into the download format + // 3. Downloads it as a .vtt file + this.subtitleDownloadJob = ioSafe { + try { + val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() + + subs?.filter { subtitle -> + downloadList.any { langTagIETF -> + subtitle.languageCode == langTagIETF || + subtitle.originalName.contains( + fromTagToEnglishLanguageName( + langTagIETF + ) ?: langTagIETF + ) + } + } + ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } + ?.take(3) // max subtitles download hardcoded (?_?) + ?.forEach { link -> + val fileName = getFileName(context, meta) + downloadSubtitle(context, link, fileName, folder) + } + + } catch (_: CancellationException) { + val fileName = getFileName(context, meta) + + val info = DownloadedFileInfo( + totalBytes = 0, + relativePath = folder, + displayName = fileName, + basePath = context.getBasePath().second + ) + + deleteMatchingSubtitles(context, info) + } + } + } catch (e: Exception) { + // The work is only failed if the job did not get started + if (this.downloadJob == null) { + isFailed = true + } + logError(e) + } + } + + private suspend fun downloadEpisodeWithoutLinks() { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + + val generator = RepoLinkGenerator(listOf(downloadItem.episode)) + val currentLinks = mutableSetOf() + val currentSubs = mutableSetOf() + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + createDownloadNotification( + context, + downloadItem.apiName, + txt(R.string.loading).asString(context), + meta, + DownloadType.IsPending, + 0, + 1, + { _, _ -> }, + null, + null, + 0 + )?.let { linkLoadingNotification -> + displayNotification(context, downloadItem.episode.id, linkLoadingNotification) + } + + linkLoadingJob = ioSafe { + generator.generateLinks( + offset = 0, + isCasting = false, + clearCache = false, + sourceTypes = LOADTYPE_INAPP_DOWNLOAD, + callback = { + it.first?.let { link -> + currentLinks.add(link) + } + }, + subtitleCallback = { sub -> + currentSubs.add(sub) + }) + } + + // Wait for link loading completion + linkLoadingJob?.join() + + // Remove link loading notification + NotificationManagerCompat.from(context) + .cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) + + if (linkLoadingJob?.isCancelled == true) { + // Same as if no links, but no toast. + // Cancelled link loading is presumed to be user initiated + isCancelled = true + return + } else if (currentLinks.isEmpty()) { + main { + showToast( + R.string.no_links_found_toast, + Toast.LENGTH_SHORT + ) + } + isFailed = true + return + } else { + main { + showToast( + R.string.download_started, + Toast.LENGTH_SHORT + ) + } + } + + // Profiles should always contain a download type + val profile = QualityDataHelper.getProfiles().first { + it.types.contains( + QualityDataHelper.QualityProfileType.Download + ) + } + + val sortedLinks = currentLinks.sortedBy { link -> + // Negative, because the highest priority should be first + -getLinkPriority(profile.id, link) + } + + downloadEpisodeWithLinks( + sortedLinks, + sortSubs(currentSubs), ) - .build() - (WorkManager.getInstance(context)).enqueueUniqueWork( - key, - ExistingWorkPolicy.KEEP, - req - ) + } } - - fun downloadCheckUsingWorker( - context: Context, - ) { - startWork(context, DOWNLOAD_CHECK) - } - - fun downloadFromResumeUsingWorker( - context: Context, - pkg: DownloadResumePackage, - ) { - val key = pkg.item.ep.id.toString() - setKey(WORK_KEY_PACKAGE, key, pkg) - startWork(context, key) - } - - // Keys are needed to transfer the data to the worker reliably and without exceeding the data limit - const val WORK_KEY_PACKAGE = "work_key_package" - const val WORK_KEY_INFO = "work_key_info" - - fun downloadEpisodeUsingWorker( - context: Context, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - ) { - val info = DownloadInfo( - source, folder, ep, links - ) - - val key = info.ep.id.toString() - setKey(WORK_KEY_INFO, key, info) - startWork(context, key) - } - - data class DownloadInfo( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List - ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt new file mode 100644 index 000000000..25a9fdf2a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -0,0 +1,224 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.safefile.SafeFile +import java.io.IOException +import java.io.OutputStream +import java.util.Objects + +object DownloadObjects { + /** An item can either be something to resume or something new to start */ + data class DownloadQueueWrapper( + @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, + @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, + ) { + init { + assert(resumePackage != null || downloadItem != null) { + "ResumeID and downloadItem cannot both be null at the same time!" + } + } + + /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ + fun isCurrentlyDownloading(): Boolean { + return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } + } + + @JsonProperty("id") + val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id + + @JsonProperty("parentId") + val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId + } + + /** General data about the episode and show to start a download from. */ + data class DownloadQueueItem( + @JsonProperty("episode") val episode: ResultEpisode, + @JsonProperty("isMovie") val isMovie: Boolean, + @JsonProperty("resultName") val resultName: String, + @JsonProperty("resultType") val resultType: TvType, + @JsonProperty("resultPoster") val resultPoster: String?, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("resultId") val resultId: Int, + @JsonProperty("resultUrl") val resultUrl: String, + @JsonProperty("links") val links: List? = null, + @JsonProperty("subs") val subs: List? = null, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(null, this) + } + } + + + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + /** Tills which link should get resumed */ + @JsonProperty("linkIndex") val linkIndex: Int?, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(this, null) + } + } + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + /** Metadata for a specific episode and how to display it. */ + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath() + // Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo + @JsonProperty("linkHash") val linkHash : Int? = null + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) + + + 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, + ) + + + data class CreateNotificationMetadata( + val type: VideoDownloadManager.DownloadType, + val bytesDownloaded: Long, + 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() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + + + /** 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt new file mode 100644 index 000000000..f38664088 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,250 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.util.Log +import androidx.core.content.ContextCompat +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet + +// 1. Put a download on the queue +// 2. The queue manager starts a foreground service to handle the queue +// 3. The service starts work manager jobs to handle the downloads? +object DownloadQueueManager { + private const val TAG = "DownloadQueueManager" + const val QUEUE_KEY = "download_queue_key" + + /** Flow of all active queued download, no active downloads. + * This flow may see many changes, do not place expensive observers. + * downloadInstances is the flow keeping track of active downloads. + * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances + */ + private val _queue: MutableStateFlow> by lazy { + /** Persistent queue */ + val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() + MutableStateFlow(currentValue) + } + + val queue: StateFlow> by lazy { _queue } + + /** Start the queue, marks all queue objects as in progress. + * Note that this may run twice without the service restarting + * because MainActivity may be recreated. */ + fun init(context: Context) { + ioSafe { + _queue.collect { queue -> + setKey(QUEUE_KEY, queue) + } + } + + ioSafe startQueue@{ + // Do not automatically start the queue if safe mode is activated. + if (PluginManager.isSafeMode()) { + // Prevent misleading UI + VideoDownloadManager.cancelAllDownloadNotifications(context) + return@startQueue + } + + val resumeQueue = + getPreResumeIds().filterNot { + VideoDownloadManager.currentDownloads.value.contains(it) + } + .mapNotNull { id -> + getDownloadResumePackage(context, id)?.toWrapper() + ?: getDownloadQueuePackage(context, id) + } + + val newQueue = _queue.updateAndGet { localQueue -> + // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started + (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() + } + + // Once added to the queue they can be safely removed + removeKeys(KEY_RESUME_IN_QUEUE) + + // Make sure the download buttons display a pending status + newQueue.forEach { obj -> + setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) + } + + if (newQueue.any()) { + startQueueService(context) + } + } + } + + /** Downloads not yet started or in progress. */ + private fun getPreResumeIds(): Set { + return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { + it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() + }?.toSet() + ?: emptySet() + } + + /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ + private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { + Log.d(TAG, "Download added to queue: $downloadQueueWrapper") + val newQueue = _queue.updateAndGet { localQueue -> + // Do not add the same episode twice + if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { + return@updateAndGet localQueue + } + localQueue + downloadQueueWrapper + } + return newQueue.any { it.id == downloadQueueWrapper.id } + } + + /** Removes all objects with the same id from the internal persistent queue */ + private fun remove(id: Int) { + Log.d(TAG, "Download removed from the queue: $id") + _queue.update { localQueue -> + // The check is to prevent unnecessary updates + if (!localQueue.any { it.id == id }) { + return@update localQueue + } + + localQueue.filter { it.id != id }.toTypedArray() + } + } + + /** Removes all items and returns the previous queue */ + private fun removeAll(): Array { + Log.d(TAG, "Removed everything from queue") + return _queue.getAndUpdate { + emptyArray() + } + } + + private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + _queue.update { localQueue -> + val newIndex = newPosition.coerceIn(0, localQueue.size) + val id = downloadQueueWrapper.id + + val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { + this.add(newIndex, downloadQueueWrapper) + }.toTypedArray() + + newQueue + } + } + + /** Start a real download from the first item in the queue */ + fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { + val first = queue.value.firstOrNull() ?: return null + + remove(first.id) + + val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) + + return downloadInstance + } + + /** Marks the item as in queue for the download button */ + private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { + downloadStatusEvent.invoke( + Pair( + id, + status + ) + ) + downloadStatus[id] = status + } + + private fun startQueueService(context: Context?) { + if (context == null) { + Log.d(TAG, "Cannot start download queue service, null context.") + return + } + // Do not restart the download queue service + if (DownloadQueueService.isRunning) { + return + } + ioSafe { + val intent = DownloadQueueService.getIntent(context) + ContextCompat.startForegroundService(context, intent) + } + } + + /** Cancels an active download or removes it from queue depending on where it is. */ + fun cancelDownload(id: Int) { + Log.d(TAG, "Cancelling download: $id") + + val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } + + if (currentInstance != null) { + currentInstance.cancelDownload() + } else { + removeFromQueue(id) + } + } + + /** Removes all queued items */ + fun removeAllFromQueue() { + removeAll().forEach { wrapper -> + setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Removes all objects with the same id from the internal persistent queue */ + fun removeFromQueue(id: Int) { + ioSafe { + remove(id) + setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Will move the download queue wrapper to a new position in the queue. + * If the item does not exist it will also insert it. */ + fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + ioSafe { + reorder(downloadQueueWrapper, newPosition) + } + } + + /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ + fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { + val context = CloudStreamApp.context ?: return@safe + val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) + val isComplete = fileInfo != null && + // Assure no division by 0 + fileInfo.totalBytes > 0 && + // If more than 98% downloaded then do not add to queue + (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f + // Do not queue completed files! + if (isComplete) return@safe + + if (add(downloadQueueWrapper)) { + setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) + startQueueService(context) + } + } + + + /** Refreshes the queue flow with the same value, but copied. + * Good to run if the downloads are affected by some outside value change. */ + fun forceRefreshQueue() { + _queue.update { localQueue -> + localQueue.copyOf() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt new file mode 100644 index 000000000..9f2c31d9a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import coil3.Extras +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** Separate object with helper functions for the downloader */ +object DownloadUtils { + private val cachedBitmaps = ConcurrentHashMap() + internal fun Context.getImageBitmapFromUrl( + url: String, + headers: Map? = null + ): Bitmap? = safe { + cachedBitmaps[url]?.let { + return@safe it + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) + ?.toBitmap() + } + + bitmap?.let { + cachedBitmaps.putIfAbsent(url, it) + } + + return@safe bitmap + } + + //calculate the time + internal fun getEstimatedTimeLeft( + context: Context, + bytesPerSecond: Long, + progress: Long, + total: Long + ): String { + if (bytesPerSecond <= 0) return "" + val timeInSec = (total - progress) / bytesPerSecond + val hrs = timeInSec / 3600 + val mins = (timeInSec % 3600) / 60 + val secs = timeInSec % 60 + val timeFormated: UiText? = when { + hrs > 0 -> txt( + R.string.download_time_left_hour_min_sec_format, + hrs, + mins, + secs + ) + + mins > 0 -> txt( + R.string.download_time_left_min_sec_format, + mins, + secs + ) + + secs > 0 -> txt( + R.string.download_time_left_sec_format, + secs + ) + + else -> null + } + return timeFormated?.asString(context) ?: "" + } + + internal fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: DownloadObjects.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + + /** Helper function to make sure duplicate attributes don't get overridden 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) + * */ + internal 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 + } + + internal fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + internal suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt new file mode 100644 index 000000000..7c73a6889 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt @@ -0,0 +1,40 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Custom KSerializer for Android's [Uri] type. + * + * Uri is an Android platform type and cannot be annotated with @Serializable directly. + * Registering it in a SerializersModule globally would require a custom module passed to + * every Json instance, which adds hidden coupling. This serializer is also used sparingly + * across the codebase, so the overhead of a global registration isn't justified. + * Instead, we keep it explicit so that each usage site opts in intentionally and the + * serialization behavior remains visible. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = UriSerializer::class) + * val uri: Uri, + * ) + */ +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri { + return Uri.parse(decoder.decodeString()) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt new file mode 100644 index 000000000..0db90afea --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +class AniSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + if (data !is AnimeLoadResponse) return null // Filter actual anime + + val malId = data.getMalId()?.toIntOrNull() ?: return null + val url = + "https://api.aniskip.com/v2/skip-times/$malId/${episode.episode}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeDurationMs / 1000L}" + + val response = app.get(url).parsed() + + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + return response.results?.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + startMs = start, + endMs = end, + ) + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt new file mode 100644 index 000000000..f9254576b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt @@ -0,0 +1,370 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import java.security.MessageDigest + +class AnimeSkipAuth : AuthAPI() { + override val name = "AnimeSkip" + override val inAppLoginRequirement: AuthLoginRequirement = + AuthLoginRequirement(password = true, username = true) + override val idPrefix = "anime-skip" + override val hasInApp = true + override val createAccountUrl = "https://anime-skip.com/account" + val baseClientId = "as1JgiMbW4wKfmTLWXS79iTDQFll76pk" + fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + + data class LoginRoot( + @JsonProperty("data") + val data: LoginData, + ) + + data class LoginData( + @JsonProperty("login") + val login: Login, + ) + + data class Login( + @JsonProperty("authToken") + val authToken: String, + @JsonProperty("refreshToken") + val refreshToken: String, + @JsonProperty("account") + val account: Account, + ) + + data class ApiRoot( + @JsonProperty("data") + val data: ApiData, + ) + + data class ApiData( + @JsonProperty("myApiClients") + val myApiClients: List, + ) + + data class MyApiClient( + @JsonProperty("id") + val id: String, + ) + + data class Account( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + ) + + data class Payload( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + @JsonProperty("clientId") + val clientId: String, + ) + + override suspend fun user(token: AuthToken?): AuthUser? { + val payload = parseJson(token?.payload ?: return null) + return AuthUser( + name = payload.username, + id = payload.email.hashCode(), + profilePicture = payload.profileUrl + ) + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val hash = md5(form.password ?: return null) + val emailOrUserName = form.email ?: form.username ?: return null + + val loginQuery = """ + { + login(usernameEmail: "$emailOrUserName", passwordHash: "$hash") { + authToken + refreshToken + account { + profileUrl + username + email + } + } + } +""" + val loginRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to loginQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val authToken = loginRoot.data.login.authToken + val refreshToken = loginRoot.data.login.refreshToken + val account = loginRoot.data.login.account + + val clientQuery = """ + { + myApiClients { + id + } + } + """.trimIndent() + + val apiRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to clientQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "Authorization" to "Bearer $authToken", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val clientId = apiRoot.data.myApiClients.getOrNull(0)?.id + ?: throw ErrorLoadingException("No API token found") + + val payload = Payload( + profileUrl = account.profileUrl, + username = account.username, + email = account.email, + clientId = clientId, + ) + return AuthToken( + accessToken = authToken, + refreshToken = refreshToken, + payload = payload.toJson() + ) + } +} + +class AnimeSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + val auth = PlainAuthRepo(animeSkipApi) + //val clientId = "ZGfO0sMF3eCwLYf8yMSCJjlynwNGRXWE" + + companion object { + const val MIN_LENGTH: Int = 4 + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String?): String? = + name?.replace(strip, "")?.lowercase() + + private val asciiRegex = Regex("[^a-zA-Z0-9 ]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun asciiName(name: String?): String? = + name?.replace(asciiRegex, "")?.lowercase() + } + + data class Root( + @JsonProperty("data") + val data: Data, + ) + + data class Data( + @JsonProperty("searchShows") + val searchShows: List, + ) + + data class SearchShow( + @JsonProperty("name") + val name: String, + @JsonProperty("originalName") + val originalName: String?, + @JsonProperty("seasonCount") + val seasonCount: Long, + @JsonProperty("episodeCount") + val episodeCount: Long, + @JsonProperty("baseDuration") + val baseDuration: Double, + @JsonProperty("episodes") + val episodes: List, + ) + + data class Episode( + @JsonProperty("number") + val number: String?, + @JsonProperty("absoluteNumber") + val absoluteNumber: String?, + @JsonProperty("season") + val season: String?, + @JsonProperty("timestamps") + val timestamps: List, + ) + + data class Timestamp( + @JsonProperty("at") + val at: Double, + @JsonProperty("type") + val type: Type, + ) + + data class Type( + @JsonProperty("name") + val name: String, + ) + + val cache: ConcurrentHashMap = ConcurrentHashMap() + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val clientId : String = parseJson( + auth.authData()?.token?.payload ?: return null + ).clientId + + when (data) { + is AnimeLoadResponse, is TvSeriesLoadResponse -> { + /** Require episode based anime */ + } + + else -> return null + } + + val query = """{ + searchShows(search: "${data.name}", limit: 1) { + name + originalName + seasonCount + episodeCount + episodes { + number + absoluteNumber + season + baseDuration + timestamps { + at + type { + name + } + } + } + } +}""" + val root = cache[data.name] ?: run { + app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to query), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to clientId + ) + ) + .parsed().data.also { root -> + cache[data.name] = root + } + } + val show = root.searchShows.firstOrNull { show -> + /** Match ascii */ + val ascii1 = asciiName(data.name) + val ascii2 = asciiName(show.name) + if (ascii1 == ascii2 && (ascii1?.length ?: 0) > MIN_LENGTH) { + return@firstOrNull true + } + + if (data !is AnimeLoadResponse) { + return@firstOrNull false + } + + /** Match original name */ + val strip1 = stripName(show.originalName) + val strip2 = stripName(data.japName) + + /** Match english name*/ + val ascii3 = stripName(data.engName) + (strip1 == strip2 && (strip1?.length ?: 0) > MIN_LENGTH) || + (ascii2 == ascii3 && (ascii2?.length ?: 0) > MIN_LENGTH) + } ?: return null + + val showEpisode = when (data) { + is AnimeLoadResponse -> { + val episodeNumber = episode.episode.toString() + /** For anime, match on number */ + show.episodes.firstOrNull { + it.absoluteNumber == episodeNumber + } ?: show.episodes.firstOrNull { + it.number == episodeNumber + } + } + + is TvSeriesLoadResponse -> { + /** For tv-series, match on season + number */ + val seasonNumber = episode.season?.toString() + val episodeNumber = episode.episode.toString() + val episodeIndex = episode.totalEpisodeIndex.toString() + + show.episodes.firstOrNull { + it.season == seasonNumber && it.number == episodeNumber + } ?: show.episodes.firstOrNull { + it.absoluteNumber == episodeIndex + } + } + + else -> null + } ?: return null + + val result = ArrayList() + var pending: SkipStamp? = null + for (stamp in showEpisode.timestamps) { + val startMS = (stamp.at * 1000.0).toLong() + pending?.let { pending -> + result.add(pending.copy(endMs = startMS)) + } + val type = when (stamp.type.name) { + "Intro", "New Intro" -> SkipType.Intro + "Credits" -> SkipType.Credits + "Preview" -> SkipType.Preview + "Recap" -> SkipType.Recap + "Mixed Credits" -> SkipType.MixedEnding + "Filler", "Transition", "Branding", "Canon", "Title Card" -> null + else -> null + } + if (type == null) { + pending = null + continue + } + pending = SkipStamp(type, startMS, 0L) + } + pending?.let { pending -> + result.add(pending.copy(endMs = episodeDurationMs)) + /** Base duration = fucked */ + } + + return result + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt new file mode 100644 index 000000000..869515f43 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +class IntroDbSkip : SkipAPI() { + override val name = "IntroDb" + + override val supportedTypes = setOf(TvType.TvSeries, TvType.AsianDrama) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val season = episode.season ?: return null + val imdbId = data.getImdbId() ?: return null + + val url = + "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=${episode.episode}" + val response = app.get(url).parsed() + + return listOfNotNull( + response.intro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Opening, + startMs = start, + endMs = end + ) + }, + response.recap?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Recap, + startMs = start, + endMs = end + ) + }, + response.outro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Ending, + startMs = start, + endMs = end + ) + } + ) + } + + + data class IntroDbResponse( + @JsonProperty("imdb_id") val imdbId: String?, + val season: Int?, + val episode: Int?, + val intro: Segment?, + val recap: Segment?, + val outro: Segment?, + ) + + data class Segment( + @JsonProperty("start_sec") val startSec: Double?, + @JsonProperty("end_sec") val endSec: Double?, + @JsonProperty("start_ms") val startMs: Long?, + @JsonProperty("end_ms") val endMs: Long?, + val confidence: Double?, + @JsonProperty("submission_count") val submissionCount: Int?, + @JsonProperty("updated_at") val updatedAt: String?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt new file mode 100644 index 000000000..60cc3ae1e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import androidx.annotation.StringRes +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import java.util.concurrent.ConcurrentHashMap + + +enum class SkipType(@StringRes val res: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_credits), + Intro(R.string.skip_type_intro), + Preview(R.string.skip_type_preview), +} + +data class SkipStamp( + val type: SkipType, + /** Start position in milliseconds of the skip, where it should start showing up */ + val startMs: Long, + /** End position in milliseconds of the skip, where it will skip to */ + val endMs: Long, + /** Custom visual label instead of using the type. Only use this for content not covered by SkipType */ + val label: String? = null, +) + +data class VideoSkipStamp( + val timestamp: SkipStamp, + val skipToNextEpisode: Boolean, + val source: String, +) { + val uiText = + if (skipToNextEpisode) txt(R.string.next_episode) else + txt( + R.string.skip_type_format, + timestamp.label?.let { txt(it) } ?: txt(timestamp.type.res) + ) +} + +abstract class SkipAPI { + open val name: String = "NONE" + + /** On what types SkipAPI should trigger on */ + abstract val supportedTypes: Set + + /** Get all video skip stamps of the associated episode */ + @Throws + open suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + ): List? { + throw NotImplementedError() + } + + companion object { + private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip(), AnimeSkip()) + private val cachedStamps = ConcurrentHashMap>() + + /** Get all video timestamps from an episode */ + suspend fun videoStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + for (api in skipApis) { + /** Unsupported type, so we do not waste a get call */ + if (!api.supportedTypes.contains(data.type)) { + continue + } + + /** Find first non-empty stamps */ + val stamps = safeAsync { api.stamps(data, episode, episodeDurationMs) } + if (stamps.isNullOrEmpty()) { + continue + } + + return stamps.map { stamp -> + VideoSkipStamp( + timestamp = stamp, + skipToNextEpisode = hasNextEpisode && episodeDurationMs - stamp.endMs < 20_000L, + source = api.name + ) + }.also { stamps -> + /** Put in cache, this is such small data, it should be fine to never clear it */ + cachedStamps[episode.id] = stamps + } + } + return emptyList() + } + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt new file mode 100644 index 000000000..cc2661cb0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.app + +/** https://theintrodb.org/docs */ +class TheIntroDBSkip : SkipAPI() { + override val name = "TheIntroDB" + override val supportedTypes = setOf( + TvType.TvSeries, TvType.Cartoon, TvType.Anime, TvType.Movie, + TvType.AsianDrama + ) + + val mainUrl = "https://api.theintrodb.org" + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val idSuffix = + data.getTMDbId()?.let { tmdbId -> "tmdb_id=$tmdbId" } + ?: data.getImdbId()?.let { imdbId -> "imdb_id=$imdbId" } + ?: return null + + val url = if (data.isMovie()) { + "$mainUrl/v2/media?$idSuffix" + } else { + val season = episode.season ?: return null + "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" + } + val root = app.get(url).parsed() + return arrayOf( + root.intro to SkipType.Intro, + root.credits to SkipType.Credits, + root.recap to SkipType.Recap, + root.preview to SkipType.Preview + ).map { (list, type) -> + list.map { stamp -> + SkipStamp( + type, + stamp.startMs ?: 0L, + stamp.endMs ?: episodeDurationMs + ) + } + }.flatten() + } + + data class Root( + @JsonProperty("tmdb_id") + val tmdbId: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("intro") + val intro: List = emptyList(), + @JsonProperty("recap") + val recap: List = emptyList(), + @JsonProperty("credits") + val credits: List = emptyList(), + @JsonProperty("preview") + val preview: List = emptyList(), + ) + + data class Stamp( + @JsonProperty("start_ms") + val startMs: Long?, + @JsonProperty("end_ms") + val endMs: Long?, + ) +} \ No newline at end of file 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 624370032..c18ad39c6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.ViewGroup +import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.view.marginEnd import com.lagradost.cloudstream3.R @@ -19,9 +20,9 @@ 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) - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -104,9 +105,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - spacing = 0//t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + spacing = 0 + } } internal constructor(width: Int, height: Int) : super(width, height) { diff --git a/app/src/main/res/color/black_button_ripple.xml b/app/src/main/res/color/black_button_ripple.xml new file mode 100644 index 000000000..d2a6b6c4d --- /dev/null +++ b/app/src/main/res/color/black_button_ripple.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/item_select_color_tv.xml b/app/src/main/res/color/item_select_color_tv.xml new file mode 100644 index 000000000..3042fd588 --- /dev/null +++ b/app/src/main/res/color/item_select_color_tv.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/animeskip.xml b/app/src/main/res/drawable/animeskip.xml new file mode 100644 index 000000000..8f1bb3105 --- /dev/null +++ b/app/src/main/res/drawable/animeskip.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_color_both.xml b/app/src/main/res/drawable/bg_color_both.xml new file mode 100644 index 000000000..bb71f8731 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_both.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_bottom.xml b/app/src/main/res/drawable/bg_color_bottom.xml new file mode 100644 index 000000000..7c744f19f --- /dev/null +++ b/app/src/main/res/drawable/bg_color_bottom.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_center.xml b/app/src/main/res/drawable/bg_color_center.xml new file mode 100644 index 000000000..7cb437452 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_center.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_top.xml b/app/src/main/res/drawable/bg_color_top.xml new file mode 100644 index 000000000..45497d272 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_top.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_imdb_badge.xml b/app/src/main/res/drawable/bg_imdb_badge.xml new file mode 100644 index 000000000..de7a6704b --- /dev/null +++ b/app/src/main/res/drawable/bg_imdb_badge.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml new file mode 100644 index 000000000..b4701e42a --- /dev/null +++ b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bookmark_star_24px.xml b/app/src/main/res/drawable/bookmark_star_24px.xml new file mode 100644 index 000000000..81b400d92 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml new file mode 100644 index 000000000..dbbc7dc9f --- /dev/null +++ b/app/src/main/res/drawable/clear_all_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml new file mode 100644 index 000000000..737ff1959 --- /dev/null +++ b/app/src/main/res/drawable/dashed_line_horizontal.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/go_back_30.xml b/app/src/main/res/drawable/go_back_30.xml index e57946b65..149990116 100644 --- a/app/src/main/res/drawable/go_back_30.xml +++ b/app/src/main/res/drawable/go_back_30.xml @@ -1,6 +1,7 @@ - - diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml index 70db409b3..7dea8241e 100644 --- a/app/src/main/res/drawable/ic_baseline_close_24.xml +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_baseline_exit_24.xml b/app/src/main/res/drawable/ic_baseline_exit_24.xml index bf421c227..6aebfabdc 100644 --- a/app/src/main/res/drawable/ic_baseline_exit_24.xml +++ b/app/src/main/res/drawable/ic_baseline_exit_24.xml @@ -1,5 +1,13 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml index 6e130c3c9..66afaed2c 100644 --- a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml +++ b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_baseline_north_west_24.xml b/app/src/main/res/drawable/ic_baseline_north_west_24.xml new file mode 100644 index 000000000..c46eb4b0c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_north_west_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml new file mode 100644 index 000000000..2e7c9b070 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_people_24.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_offline_pin_24.xml b/app/src/main/res/drawable/ic_offline_pin_24.xml new file mode 100644 index 000000000..455006b31 --- /dev/null +++ b/app/src/main/res/drawable/ic_offline_pin_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..e61dcf1ce --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/kid_star_24px.xml b/app/src/main/res/drawable/kid_star_24px.xml new file mode 100644 index 000000000..2efe84195 --- /dev/null +++ b/app/src/main/res/drawable/kid_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/metadata_overlay_icon.xml b/app/src/main/res/drawable/metadata_overlay_icon.xml new file mode 100644 index 000000000..6d1b6510a --- /dev/null +++ b/app/src/main/res/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml new file mode 100644 index 000000000..8ef633fd2 --- /dev/null +++ b/app/src/main/res/drawable/netflix_download_batch.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml new file mode 100644 index 000000000..b94500279 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_15_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_20_gray.xml b/app/src/main/res/drawable/outline_big_20_gray.xml new file mode 100644 index 000000000..ebcdc0bf4 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_20_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_25_gray.xml b/app/src/main/res/drawable/outline_big_25_gray.xml new file mode 100644 index 000000000..ea5f31a1f --- /dev/null +++ b/app/src/main/res/drawable/outline_big_25_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_35_gray.xml b/app/src/main/res/drawable/outline_big_35_gray.xml new file mode 100644 index 000000000..ab18a1354 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_35_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/play_button.xml b/app/src/main/res/drawable/play_button.xml index 04886b6e5..ee3d47dfe 100644 --- a/app/src/main/res/drawable/play_button.xml +++ b/app/src/main/res/drawable/play_button.xml @@ -1,25 +1,19 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + android:name="vector" + android:width="842dp" + android:height="842dp" + android:viewportWidth="842" + android:viewportHeight="842"> + android:name="path" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:fillColor="#B3000000" + android:strokeWidth="1"/> - + android:name="path_2" + android:pathData="M 598.91 419.24 L 333.91 266.24 L 333.91 572.24 L 598.91 419.24 Z" + android:fillColor="#ffffff" + android:strokeWidth="1"/> diff --git a/app/src/main/res/drawable/play_button_transparent.xml b/app/src/main/res/drawable/play_button_transparent.xml new file mode 100644 index 000000000..caa7041e6 --- /dev/null +++ b/app/src/main/res/drawable/play_button_transparent.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/player_gradient_tv.xml b/app/src/main/res/drawable/player_gradient_tv.xml index 79bb3af5f..8077b418f 100644 --- a/app/src/main/res/drawable/player_gradient_tv.xml +++ b/app/src/main/res/drawable/player_gradient_tv.xml @@ -4,10 +4,10 @@ @@ -15,10 +15,10 @@ diff --git a/app/src/main/res/drawable/rating_bg_color.xml b/app/src/main/res/drawable/rating_bg_color.xml index 3ae9b4f84..4cf33aba0 100644 --- a/app/src/main/res/drawable/rating_bg_color.xml +++ b/app/src/main/res/drawable/rating_bg_color.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 000000000..d1360f948 --- /dev/null +++ b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_select_ripple.xml b/app/src/main/res/drawable/rounded_select_ripple.xml new file mode 100644 index 000000000..5dd7559b3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_select_ripple.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/speedup.xml b/app/src/main/res/drawable/speedup.xml index 79ef428ac..879ef852c 100644 --- a/app/src/main/res/drawable/speedup.xml +++ b/app/src/main/res/drawable/speedup.xml @@ -1,12 +1,10 @@ - - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/subdl_logo_big.xml b/app/src/main/res/drawable/subdl_logo_big.xml index a6cbb3115..12116eabc 100644 --- a/app/src/main/res/drawable/subdl_logo_big.xml +++ b/app/src/main/res/drawable/subdl_logo_big.xml @@ -1,10 +1,12 @@ - - - + android:viewportHeight="320"> + + + diff --git a/app/src/main/res/drawable/sun_7_24.xml b/app/src/main/res/drawable/sun_7_24.xml new file mode 100644 index 000000000..26e3f43e8 --- /dev/null +++ b/app/src/main/res/drawable/sun_7_24.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/video_outline.xml b/app/src/main/res/drawable/video_outline.xml new file mode 100644 index 000000000..558c4ec3e --- /dev/null +++ b/app/src/main/res/drawable/video_outline.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-port/player_select_source_and_subs.xml b/app/src/main/res/layout-port/player_select_source_and_subs.xml new file mode 100644 index 000000000..4710473d4 --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_and_subs.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/player_select_source_priority.xml b/app/src/main/res/layout-port/player_select_source_priority.xml new file mode 100644 index 000000000..2cba9c869 --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_priority.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/subtitle_offset.xml b/app/src/main/res/layout-port/subtitle_offset.xml new file mode 100644 index 000000000..b6c4f61fd --- /dev/null +++ b/app/src/main/res/layout-port/subtitle_offset.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml index 066b94342..f52c8ea51 100644 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -40,28 +40,28 @@ + app:cardCornerRadius="@dimen/rounded_image_radius"> + + android:layout_gravity="center" + android:contentDescription="@string/preview_background_img_des" + android:focusable="true" + android:foreground="@drawable/outline_drawable_forced_round" + android:scaleType="centerCrop" + android:src="@drawable/profile_bg_blue" /> + + android:padding="5dp" + android:src="@drawable/ic_baseline_edit_24" /> + android:buttonTint="?attr/textColor" + android:text="@string/lock_profile" + android:textColor="?attr/grayTextColor" /> + android:layout_marginTop="-60dp" + android:orientation="horizontal" + android:padding="10dp"> + android:text="@string/delete" /> + android:text="@string/sort_apply" /> + android:text="@string/sort_cancel" /> \ 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 index f133d6c3f..3cbfc72fb 100644 --- a/app/src/main/res/layout/account_list_item.xml +++ b/app/src/main/res/layout/account_list_item.xml @@ -6,11 +6,11 @@ android:id="@+id/card_view" android:layout_width="110dp" android:layout_height="110dp" - android:animateLayoutChanges="true" - android:backgroundTint="?attr/primaryGrayBackground" - android:foreground="?attr/selectableItemBackground" android:layout_margin="10dp" + android:animateLayoutChanges="true" + android:backgroundTint="@color/primaryGrayBackground" android:focusable="true" + android:foreground="?attr/selectableItemBackground" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,6 +42,7 @@ android:layout_margin="4dp" android:src="@drawable/video_locked" android:visibility="gone" + app:tint="@color/textColor" tools:visibility="visible" /> + android:textColor="@color/textColor" + android:textSize="16sp" + tools:text="Hello World!" /> \ 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 index 0adade19f..3f41a23c2 100644 --- a/app/src/main/res/layout/account_list_item_edit.xml +++ b/app/src/main/res/layout/account_list_item_edit.xml @@ -6,11 +6,11 @@ android:id="@+id/card_view" android:layout_width="110dp" android:layout_height="110dp" - android:animateLayoutChanges="true" - android:backgroundTint="?attr/primaryGrayBackground" - android:foreground="?attr/selectableItemBackground" android:layout_margin="10dp" + android:animateLayoutChanges="true" + android:backgroundTint="@color/primaryGrayBackground" android:focusable="true" + android:foreground="?attr/selectableItemBackground" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,6 +42,7 @@ android:layout_margin="4dp" android:src="@drawable/video_locked" android:visibility="gone" + app:tint="@color/textColor" tools:visibility="visible" /> + android:src="@drawable/ic_baseline_edit_24" + app:tint="@color/textColor" /> + android:textColor="@color/textColor" + android:textSize="16sp" + tools:text="Hello World!" /> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_account_select.xml b/app/src/main/res/layout/activity_account_select.xml index bd6007dcf..9f62d5601 100644 --- a/app/src/main/res/layout/activity_account_select.xml +++ b/app/src/main/res/layout/activity_account_select.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - + - + android:clickable="false" + android:focusable="false" + android:focusableInTouchMode="false" + android:importantForAccessibility="no" + android:src="@drawable/outline" + android:visibility="gone" /> \ No newline at end of file diff --git a/app/src/main/res/layout/add_account_input.xml b/app/src/main/res/layout/add_account_input.xml index ea48a80f0..4f96b109e 100644 --- a/app/src/main/res/layout/add_account_input.xml +++ b/app/src/main/res/layout/add_account_input.xml @@ -80,6 +80,7 @@ android:id="@+id/login_server_input" android:layout_width="match_parent" android:layout_height="wrap_content" + android:autofillHints="no" android:hint="@string/example_ip" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" @@ -96,7 +97,7 @@ android:layout_height="wrap_content" android:autofillHints="password" android:hint="@string/example_password" - android:inputType="textVisiblePassword" + android:inputType="textPassword" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" diff --git a/app/src/main/res/layout/add_repo_input.xml b/app/src/main/res/layout/add_repo_input.xml index cb4224d10..a8bdf2a38 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -81,6 +81,7 @@ android:id="@+id/repo_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" + android:autofillHints="no" android:hint="@string/repository_url_hint" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" diff --git a/app/src/main/res/layout/add_site_input.xml b/app/src/main/res/layout/add_site_input.xml index 1c61f8b4d..519b790da 100644 --- a/app/src/main/res/layout/add_site_input.xml +++ b/app/src/main/res/layout/add_site_input.xml @@ -62,6 +62,7 @@ + xmlns:tools="http://schemas.android.com/tools" + android:nextFocusDown="@id/nginx_text_input" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/nginx_text_input" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + android:layout_marginBottom="60dp" + android:layout_marginHorizontal="10dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:autofillHints="no" + android:inputType="text" + tools:text="nginx.com" + tools:ignore="LabelFor" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + style="@style/WhiteButton" /> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_selection_dialog.xml b/app/src/main/res/layout/bottom_selection_dialog.xml index 0532f2506..55ca6562e 100644 --- a/app/src/main/res/layout/bottom_selection_dialog.xml +++ b/app/src/main/res/layout/bottom_selection_dialog.xml @@ -1,58 +1,65 @@ + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> - + + + + + tools:text="Test" /> + + android:id="@+id/listview1" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:paddingTop="10dp" + android:dividerHeight="1dp" + android:requiresFadingEdge="vertical" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + tools:listitem="@layout/sort_bottom_single_choice" /> + android:id="@+id/apply_btt_holder" + android:layout_width="match_parent" + android:layout_height="60dp" + android:gravity="center_vertical|end" + android:orientation="horizontal"> + android:id="@+id/apply_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + style="@style/WhiteButton" /> + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 99a9750b2..4f7bdf74d 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -7,9 +7,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="5dp" + android:focusable="true" android:foreground="@drawable/outline_drawable" app:cardBackgroundColor="@color/transparent" - android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> @@ -25,38 +25,42 @@ android:layout_gravity="center_horizontal"> - - - - - - + + + + + + diff --git a/app/src/main/res/layout/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 4d3b50dfe..92d0bd350 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,16 +1,21 @@ - + + + android:layout_height="match_parent"> - + android:layout_height="wrap_content" + android:orientation="vertical"> - - - - - + - - - - - - - - - - - - + + - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/confirm_exit_dialog.xml b/app/src/main/res/layout/confirm_exit_dialog.xml index 518aaa477..c312e64e3 100644 --- a/app/src/main/res/layout/confirm_exit_dialog.xml +++ b/app/src/main/res/layout/confirm_exit_dialog.xml @@ -5,9 +5,11 @@ android:orientation="vertical" android:paddingHorizontal="16dp" android:paddingVertical="8dp"> + + android:text="@string/dont_show_again" + android:textColor="?attr/grayTextColor" /> diff --git a/app/src/main/res/layout/custom_preference_category_material.xml b/app/src/main/res/layout/custom_preference_category_material.xml index 06db99017..f5d78e835 100644 --- a/app/src/main/res/layout/custom_preference_category_material.xml +++ b/app/src/main/res/layout/custom_preference_category_material.xml @@ -19,9 +19,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingLeft="?android:attr/listPreferredItemPaddingLeft" android:paddingStart="?android:attr/listPreferredItemPaddingStart" - android:paddingRight="?android:attr/listPreferredItemPaddingRight" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:background="@drawable/outline_drawable_less" android:baselineAligned="false" @@ -52,7 +50,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@android:id/title" - android:layout_alignLeft="@android:id/title" android:layout_alignStart="@android:id/title" android:layout_gravity="start" android:textAlignment="viewStart" diff --git a/app/src/main/res/layout/custom_preference_material.xml b/app/src/main/res/layout/custom_preference_material.xml index 0ab98c22b..c6685ee29 100644 --- a/app/src/main/res/layout/custom_preference_material.xml +++ b/app/src/main/res/layout/custom_preference_material.xml @@ -21,9 +21,7 @@ android:layout_height="wrap_content" android:minHeight="?android:attr/listPreferredItemHeightSmall" android:gravity="center_vertical" - android:paddingLeft="?android:attr/listPreferredItemPaddingLeft" android:paddingStart="?android:attr/listPreferredItemPaddingStart" - android:paddingRight="?android:attr/listPreferredItemPaddingRight" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:background="?attr/focusBackground" android:clipToPadding="false" @@ -51,7 +49,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@android:id/title" - android:layout_alignLeft="@android:id/title" android:layout_alignStart="@android:id/title" android:layout_gravity="start" android:textAlignment="viewStart" @@ -67,9 +64,7 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:gravity="end|center_vertical" - android:paddingLeft="16dp" android:paddingStart="16dp" - android:paddingRight="0dp" android:paddingEnd="0dp" android:orientation="vertical"/> diff --git a/app/src/main/res/layout/custom_preference_widget_seekbar.xml b/app/src/main/res/layout/custom_preference_widget_seekbar.xml index 02c5ec1be..132091e5f 100644 --- a/app/src/main/res/layout/custom_preference_widget_seekbar.xml +++ b/app/src/main/res/layout/custom_preference_widget_seekbar.xml @@ -18,13 +18,12 @@ + android:ellipsize="marquee" + tools:ignore="LabelFor" /> @@ -99,9 +96,7 @@ android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" - android:paddingLeft="@dimen/preference_seekbar_padding_horizontal" android:paddingStart="@dimen/preference_seekbar_padding_horizontal" - android:paddingRight="@dimen/preference_seekbar_padding_horizontal" android:paddingEnd="@dimen/preference_seekbar_padding_horizontal" android:paddingTop="@dimen/preference_seekbar_padding_vertical" android:paddingBottom="@dimen/preference_seekbar_padding_vertical" @@ -113,13 +108,11 @@ + - - + tools:ignore="UseCompoundDrawables" + android:padding="10dp"> + android:layout_marginEnd="10dp" + android:background="@drawable/search_background"> + - - - - - - - - + - + - - diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index e53e63d31..cb9c13d53 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -14,30 +14,44 @@ app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> - + + + + + + + + + + - + + + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/empty_layout.xml b/app/src/main/res/layout/empty_layout.xml index 388e862b2..e128f7cec 100644 --- a/app/src/main/res/layout/empty_layout.xml +++ b/app/src/main/res/layout/empty_layout.xml @@ -1,18 +1,19 @@ - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/extra_brightness_overlay.xml b/app/src/main/res/layout/extra_brightness_overlay.xml new file mode 100644 index 000000000..8f82121bb --- /dev/null +++ b/app/src/main/res/layout/extra_brightness_overlay.xml @@ -0,0 +1,8 @@ + + \ 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 c3ab356c2..0a7b42327 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -12,14 +12,14 @@ + android:background="?attr/primaryGrayBackground"> @@ -33,7 +33,7 @@ android:padding="8dp" android:layout_gravity="center_vertical" android:nextFocusLeft="@id/navigation_downloads" - app:tint="@android:color/white" /> + app:tint="?attr/white" />