diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000000..438a3c761d0 --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,68 @@ +name: Desktop Build + +# Checks a pull request: the tests, and that the desktop app compiles. Nothing runs on the merge +# itself, the pull request was checked already. The installer is only built when the workflow is +# run by hand ("Run workflow" in the Actions tab), to try a build before releasing it. + +on: + pull_request: + paths-ignore: + - '**.md' + - '.github/workflows/libmpv.yml' + - '.github/workflows/release.yml' + workflow_dispatch: + +permissions: + contents: read + +# A new push to a pull request stops the check of the commit before it +concurrency: + group: desktop-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + windows: + runs-on: windows-latest + timeout-minutes: 40 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: ${{ github.event_name == 'pull_request' }} + + - name: Run tests + run: ./gradlew :library:jvmTest :shared:jvmTest :desktopApp:jvmTest + + - name: Build MSI + if: github.event_name == 'workflow_dispatch' + run: ./gradlew :desktopApp:packageMsi + + - name: Upload MSI + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v7 + with: + name: desktop-windows-msi + path: desktopApp/build/compose/binaries/main/msi/*.msi + if-no-files-found: error + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: desktop-test-reports + path: | + library/build/reports/tests/jvmTest/ + shared/build/reports/tests/jvmTest/ + desktopApp/build/reports/tests/jvmTest/ diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml new file mode 100644 index 00000000000..855bcbaa0d4 --- /dev/null +++ b/.github/workflows/libmpv.yml @@ -0,0 +1,317 @@ +name: libmpv + +# Builds libmpv-2.dll for the Windows app with shinchiro's mpv-winbuild-cmake, and keeps the complete +# source it was built from. libmpv includes GPL parts (FFmpeg, x264 and others), so every app release +# must come with that source. The build scripts follow each library's main branch and record nothing, +# so the commits are written down here, after the build, from the source folders themselves. +# +# It builds with Clang, as shinchiro's published builds are: their GCC build has not passed since +# April 2026. The LLVM toolchain takes hours, so it is built in a job of its own and kept in the +# cache; it is rebuilt only when the build scripts change or the cache has expired. +# +# Run it by hand with "publish" to make a release holding the build, its source and the list of +# versions; desktopApp/build.gradle.kts then points at that release. A pull request that changes this +# file runs it without publishing, to check it still builds. + +on: + workflow_dispatch: + inputs: + winbuild_ref: + description: shinchiro/mpv-winbuild-cmake commit with the build scripts + default: 05a60b3cfd04e3e3b89918f4a27f3dde2935dff2 + publish: + description: Publish the build and its source as a release of this repository + type: boolean + default: false + +permissions: + contents: read + +env: + WINBUILD_REF: ${{ inputs.winbuild_ref || '05a60b3cfd04e3e3b89918f4a27f3dde2935dff2' }} + TOOLCHAIN_CACHE: clang-toolchain-v1-${{ inputs.winbuild_ref || '05a60b3cfd04e3e3b89918f4a27f3dde2935dff2' }} + +jobs: + toolchain: + runs-on: ubuntu-latest + timeout-minutes: 355 + # shinchiro's build image, with the tools the scripts expect + container: + image: ghcr.io/shinchiro/archlinux:latest + defaults: + run: + shell: bash + steps: + - name: Look for a toolchain built earlier + id: cache + uses: actions/cache/restore@v6 + with: + path: | + winbuild/clang_root + winbuild/build + key: ${{ env.TOOLCHAIN_CACHE }}-${{ github.run_id }} + restore-keys: ${{ env.TOOLCHAIN_CACHE }}- + lookup-only: true + + - name: Check out the build scripts + if: steps.cache.outputs.cache-matched-key == '' + run: | + git config --global --add safe.directory '*' + # The scripts apply their patches with git am, which needs a committer + git config --global user.name "github-actions" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git clone https://github.com/shinchiro/mpv-winbuild-cmake.git winbuild + git -C winbuild checkout --quiet "$WINBUILD_REF" + + # As shinchiro's llvm_clang.yml, without the profile-guided second pass: that makes the + # compiler faster, it does not change what the compiler builds + - name: Build LLVM, Clang and the MinGW runtime + if: steps.cache.outputs.cache-matched-key == '' + working-directory: winbuild + run: | + cmake -DTARGET_ARCH=x86_64-w64-mingw32 -DCOMPILER_TOOLCHAIN=clang -DCMAKE_INSTALL_PREFIX=$PWD/clang_root \ + -DMINGW_INSTALL_PREFIX=$PWD/build/x86_64-w64-mingw32 -DSINGLE_SOURCE_LOCATION=$PWD/src_packages \ + -DRUSTUP_LOCATION=$PWD/clang_root/install_rustup -G Ninja -B build -S . + ninja -C build llvm + ninja -C build rustup + ninja -C build cargo-clean + ninja -C build llvm-clang + rm -rf build/toolchain build/packages + + # The runtime libraries built here (libc++, compiler-rt, the MinGW CRT, winpthreads) end up in + # libmpv-2.dll, so their source is kept with the toolchain, with the commits it was built from + - name: Keep the toolchain's source + if: steps.cache.outputs.cache-matched-key == '' + working-directory: winbuild + run: | + for dir in src_packages/*/; do + lib=$(basename "$dir") + # Configuring makes an empty folder for every library, built here or not + [[ -z "$(ls -A "$dir")" ]] && continue + if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then + base=$(git -C "$dir" merge-base HEAD '@{u}' 2> /dev/null || git -C "$dir" rev-parse HEAD) + printf '%-24s %s %s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" + else + printf '%-24s from an archive, in the source as it was unpacked\n' "$lib" + fi + done > clang_root/toolchain-sources.txt + cat clang_root/toolchain-sources.txt + 7z a -t7z -mx=7 -mmt=on -xr'!.git' clang_root/toolchain-source.7z src_packages + ls -la clang_root/toolchain-source.7z + + - name: Collect the logs + if: failure() + run: find winbuild/build -type f -name '*-*.log' -print0 | tar -czf toolchain-logs.tar.gz --null -T - || true + + - name: Keep the logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: toolchain-logs + path: toolchain-logs.tar.gz + retention-days: 3 + + - name: Save the toolchain for later runs + if: steps.cache.outputs.cache-matched-key == '' + uses: actions/cache/save@v6 + with: + path: | + winbuild/clang_root + winbuild/build + key: ${{ env.TOOLCHAIN_CACHE }}-${{ github.run_id }} + + build: + needs: toolchain + runs-on: ubuntu-latest + timeout-minutes: 355 + container: + image: ghcr.io/shinchiro/archlinux:latest + defaults: + run: + shell: bash + outputs: + name: ${{ steps.package.outputs.name }} + steps: + - name: Check out the build scripts + run: | + git config --global --add safe.directory '*' + git config --global user.name "github-actions" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git clone https://github.com/shinchiro/mpv-winbuild-cmake.git winbuild + git -C winbuild checkout --quiet "$WINBUILD_REF" + git -C winbuild log -1 --format='%H %cI %s' + + # A library that follows its main branch can move past what the others it is built with + # support. Each is held back here at a commit, as the scripts already do for OpenSSL: + # - curl 978cce12 (2 October 2026) needs ASN1_STRING_get_length, which the OpenSSL commit the + # scripts pin does not have yet. Held at its parent + - name: Hold back libraries that no longer build + working-directory: winbuild + run: | + for pin in curl=318bd978ea2d36ace36b42da2174b79c3abd89a3; do + lib=${pin%%=*} commit=${pin#*=} + grep -q GIT_RESET packages/$lib.cmake && { echo "packages/$lib.cmake already holds a commit"; exit 1; } + sed -i "0,/GIT_REPOSITORY .*/s//&\n GIT_REMOTE_NAME origin\n GIT_RESET $commit/" packages/$lib.cmake + grep -nE 'GIT_(REPOSITORY|REMOTE_NAME|RESET)' packages/$lib.cmake + done + + - name: Restore the toolchain + uses: actions/cache/restore@v6 + with: + path: | + winbuild/clang_root + winbuild/build + key: ${{ env.TOOLCHAIN_CACHE }}-${{ github.run_id }} + restore-keys: ${{ env.TOOLCHAIN_CACHE }}- + fail-on-cache-miss: true + + # The compiler's cache from the last run: a library that has not changed since is not compiled + # again. Most help when a run is repeated after a failure + - name: Restore the compiler cache + uses: actions/cache/restore@v6 + with: + path: winbuild/build/x86_64-w64-mingw32/ccache + key: libmpv-ccache-${{ github.run_id }} + restore-keys: libmpv-ccache- + + # As shinchiro's mpv_clang.yml, with room in the compiler cache for every library + - name: Configure and download the sources + working-directory: winbuild + run: | + cmake -DTARGET_ARCH=x86_64-w64-mingw32 -DCOMPILER_TOOLCHAIN=clang -DCMAKE_INSTALL_PREFIX=$PWD/clang_root \ + -DMINGW_INSTALL_PREFIX=$PWD/build/x86_64-w64-mingw32 -DSINGLE_SOURCE_LOCATION=$PWD/src_packages \ + -DRUSTUP_LOCATION=$PWD/clang_root/install_rustup -DENABLE_CCACHE=ON -DCCACHE_MAXSIZE=2G -DCLANG_PACKAGES_LTO=ON \ + -G Ninja --fresh -B build -S . + ninja -C build download || true + + - name: Build mpv + working-directory: winbuild + run: ninja -C build mpv + + - name: Save the compiler cache + if: ${{ !cancelled() }} + uses: actions/cache/save@v6 + with: + path: winbuild/build/x86_64-w64-mingw32/ccache + key: libmpv-ccache-${{ github.run_id }} + + - name: Package libmpv + id: package + working-directory: winbuild + run: | + ninja -C build mpv-packaging + mkdir -p ../out + mv build/mpv-dev-x86_64-*.7z ../out/ + name=$(basename ../out/mpv-dev-x86_64-*.7z .7z) + echo "name=$name" >> "$GITHUB_OUTPUT" + (cd ../out && sha256sum "$name.7z" > "$name.7z.sha256") + + # After the build, each source folder holds the commit it was built from: the scripts update a + # library just before building it, and once built, reset it to that commit without their + # patches, which are in packages/. Configuring makes an empty folder for every library, which + # is skipped. The toolchain may come from an earlier run, so its commits are those that run + # wrote down; the libraries it built are those with a commit there + - name: Write down every version + working-directory: winbuild + run: | + name=${{ steps.package.outputs.name }} + awk '$2 ~ /^https?:/' clang_root/toolchain-sources.txt > ../toolchain-sources.txt + toolchain=" $(cut -d' ' -f1 ../toolchain-sources.txt | tr '\n' ' ') " + { + echo "# Sources of $name.7z" + echo "# Built $(date -u +%Y-%m-%dT%H:%MZ) by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "# Build scripts: https://github.com/shinchiro/mpv-winbuild-cmake $(git rev-parse HEAD)" + echo "# Container: $(grep ^PRETTY_NAME /etc/os-release | cut -d= -f2) (ghcr.io/shinchiro/archlinux)" + echo + echo "# Toolchain, its source is in clang_root/toolchain-source.7z" + cat ../toolchain-sources.txt + echo + echo "# Libraries" + for dir in src_packages/*/; do + lib=$(basename "$dir") + [[ "$toolchain" == *" $lib "* || -z "$(ls -A "$dir")" ]] && continue + if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then + base=$(git -C "$dir" merge-base HEAD '@{u}' 2> /dev/null || git -C "$dir" rev-parse HEAD) + printf '%-24s %s %s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" + else + printf '%-24s from an archive, in the source as it was unpacked\n' "$lib" + fi + done + } > ../out/$name-sources.txt + cat ../out/$name-sources.txt + + # Every library's source, the toolchain's source, the build scripts with their patches, and the + # Rust crates the Rust libraries were built with. Git history is left out, the commits are in the + # list above. The toolchain folders downloaded in this run are left out too: the toolchain was + # built from those in toolchain-source.7z. Split into parts below GitHub's 2 GB limit per file + - name: Pack the complete source + working-directory: winbuild + run: | + name=${{ steps.package.outputs.name }} + # The build tree is done with, and the runner's disk is small + rm -rf build/packages + # Links into the build tree, such as compile_commands.json, now point at nothing + find src_packages -xtype l -print -delete + df -h . + exclude=() + for lib in $(cut -d' ' -f1 ../toolchain-sources.txt); do exclude+=("-x!src_packages/$lib"); done + # The Rust crates are in the registry, and in git checkouts only when one comes from git + paths=() + for path in clang_root/install_rustup/.cargo/registry/src clang_root/install_rustup/.cargo/git/checkouts; do + [[ -d $path ]] && paths+=("$path") + done + 7z a -t7z -mx=7 -mmt=on -v1900m -xr'!.git' "${exclude[@]}" ../out/$name-source.7z \ + src_packages CMakeLists.txt README.md cargo_config.in ccache_conf.in exec.in meson_cross.txt.in toolchain.cmake.in \ + cmake packages scripts toolchain clang_root/toolchain-source.7z "${paths[@]}" \ + ../out/$name-sources.txt + ls -la ../out + (cd ../out && sha256sum $name-source.7z.* > $name-source.7z.sha256) + + - name: Collect the logs + if: failure() + run: find winbuild/build -type f \( -name '*-*.log' -o -path '*/ffbuild/config.log' \) -print0 | tar -czf build-logs.tar.gz --null -T - || true + + - name: Keep the logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: build-logs + path: build-logs.tar.gz + retention-days: 3 + + - name: Keep the build + uses: actions/upload-artifact@v7 + with: + name: libmpv + path: out/ + retention-days: 7 + compression-level: 0 + + publish: + needs: build + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: libmpv + path: out + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + NAME: ${{ needs.build.outputs.name }} + run: | + tag="libmpv-${NAME#mpv-dev-x86_64-}" + { + echo "libmpv for the Windows app, built with Clang from shinchiro/mpv-winbuild-cmake by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID." + echo + echo "- \`$NAME.7z\`: libmpv-2.dll and its headers, which the app's build downloads" + echo "- \`$NAME-source.7z.*\`: the complete source it was built from (a multi-part 7z, open the .001 part)" + echo "- \`$NAME-sources.txt\`: every library with the commit it was built from" + echo + echo "libmpv-2.dll includes GPL parts, so it is distributed under the GNU GPL version 2 or later." + } > notes.md + gh release create "$tag" out/* --repo "$GITHUB_REPOSITORY" --title "libmpv ${NAME#mpv-dev-x86_64-}" --notes-file notes.md --prerelease diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..5ec8b5c7515 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,130 @@ +name: Release + +# Publishes the Windows app, only when asked: merges into master publish nothing, so they cost no +# build of their own. +# +# - "Run workflow" in the Actions tab, on master, publishes a pre-release, v-beta., +# where is versionName in gradle/libs.versions.toml and follows the last beta of +# that version. Installed apps find it through the update check in Settings. +# - Pushing a tag v publishes that version as a full release. A tag with a suffix, such as +# v4.8.0-rc.1, makes a pre-release. After a full release, raise versionName before the next beta: +# it would otherwise come before the release itself. +# +# The release holds the installer, and its notes link to the libmpv release the build downloads +# libmpv-2.dll from, which holds the dll's complete source, as THIRD-PARTY-NOTICES.txt says. The +# source is not copied into every release: it is 462 MB, the same each time. + +on: + workflow_dispatch: + push: + tags: [ 'v*' ] + +permissions: + contents: write + +# One release at a time, so two runs close together never pick the same beta number +concurrency: + group: release + cancel-in-progress: false + +jobs: + windows: + runs-on: windows-latest + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + + - name: Only master or a tag + if: github.ref_type != 'tag' && github.ref != 'refs/heads/master' + run: | + echo "::error::Run the release on master, or push a v tag. $GITHUB_REF would publish a branch that was not merged" + exit 1 + + - name: Choose the version + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(grep -E '^versionName' gradle/libs.versions.toml | cut -d'"' -f2) + if [[ "$GITHUB_REF_TYPE" == tag ]]; then + tag=$GITHUB_REF_NAME + if [[ "${tag#v}" != "$version" && "${tag#v}" != "$version-"* ]]; then + echo "::error::Tag $tag does not match versionName $version in gradle/libs.versions.toml" + exit 1 + fi + else + if git ls-remote --exit-code --tags origin "refs/tags/v$version" > /dev/null; then + echo "::error::v$version is already released. Raise versionName in gradle/libs.versions.toml so the next betas come after it" + exit 1 + fi + last=$(git ls-remote --tags origin "refs/tags/v$version-beta.*" | sed -E 's|.*/v[^-]*-beta\.([0-9]+)$|\1|' | { grep -E '^[0-9]+$' || true; } | sort -n | tail -1) + tag=v$version-beta.$(( ${last:-0} + 1 )) + fi + # The changes listed in the notes start from the last app release + previous=$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 50 --json tagName -q '.[].tagName' | grep -E '^v[0-9]' | grep -vx "$tag" | head -1 || true) + echo "Releasing $tag, changes since ${previous:-the start}" + echo "TAG=$tag" >> "$GITHUB_ENV" + echo "PREVIOUS=$previous" >> "$GITHUB_ENV" + + # Before the build, so a release never goes out without the source it links to. Only the list of + # files is read, the source itself stays where it is + - name: Find the source of the bundled libmpv + env: + GH_TOKEN: ${{ github.token }} + run: | + libmpv=$(grep -o 'releases/download/[^/"]*' desktopApp/build.gradle.kts | head -1 | cut -d/ -f3) + echo "libmpv comes from release $libmpv" + assets=$(gh release view "$libmpv" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name') + echo "$assets" + if ! grep -q -- '-source.7z.001$' <<< "$assets"; then + echo "::error::Release $libmpv has no libmpv source. Build libmpv with the libmpv workflow (publish) and point desktopApp/build.gradle.kts at it" + exit 1 + fi + echo "LIBMPV=$libmpv" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + # Releases run on master or a tag: their cache serves the pull requests' checks + cache-read-only: false + + - name: Run tests + run: ./gradlew :library:jvmTest :shared:jvmTest + + # The app knows its full version, pre-release included, for the update check. The MSI version + # takes the run number as its last part: Windows only upgrades to a higher MSI version, and two + # releases of one version, such as 4.8.0-beta.1 and 4.8.0-beta.2, must still upgrade + - name: Build MSI + run: | + version=${TAG#v} + core=${version%%-*} + ./gradlew :desktopApp:packageMsi -PappVersion=$version -PmsiVersion=${core%.*}.$GITHUB_RUN_NUMBER + + # Creates the tag on the merged commit when there is none yet + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + msi=$(ls desktopApp/build/compose/binaries/main/msi/*.msi) + prerelease=$([[ "$TAG" == *-* ]] && echo --prerelease || true) + { + echo "CloudStream for Windows ${TAG#v}." + echo + echo "Download \`$(basename "$msi")\` and open it to install, or to update an installed version. Installed apps also find this release in Settings." + echo + echo "SHA-256 of the installer: \`$(sha256sum "$msi" | cut -d' ' -f1)\`" + echo + echo "The complete source of the libmpv-2.dll the app plays video with (GPL, see THIRD-PARTY-NOTICES.txt) is in the [$LIBMPV](https://github.com/$GITHUB_REPOSITORY/releases/tag/$LIBMPV) release: the \`*-source.7z.*\` files, with the list of every library's version in \`*-sources.txt\`." + } > notes.md + gh release create "$TAG" "$msi" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + --title "CloudStream ${TAG#v}" --notes-file notes.md --generate-notes \ + ${PREVIOUS:+--notes-start-tag "$PREVIOUS"} $prerelease diff --git a/androidCompat/build.gradle.kts b/androidCompat/build.gradle.kts new file mode 100644 index 00000000000..22dda0088a3 --- /dev/null +++ b/androidCompat/build.gradle.kts @@ -0,0 +1,45 @@ +/** + * Lets desktop run extensions that were only built for Android. + * + * It converts an extension's .cs3 (Android dex) into a jar, and provides desktop versions of the + * Android and CloudStream app classes extensions use. Android classes it has no desktop version of + * are generated as empty stubs when an extension loads, see AndroidPluginClassLoader. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +kotlin { + jvm() + + sourceSets { + all { + languageSettings { + optIn("com.lagradost.cloudstream3.InternalAPI") + optIn("com.lagradost.cloudstream3.Prerelease") + } + } + + jvmMain.dependencies { + api(project(":library")) + // Android has Gson built in through its libraries, extensions use it without bundling it + api(libs.gson) + implementation(libs.jackson.module.kotlin) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.nicehttp) + implementation(libs.okhttp) + } + + jvmTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + +dependencies { + // dex2jar's d2j-external has its own copy of the ASM core classes, the one its command line + // tool runs with, so leave out the separate ASM core to have one copy only + "jvmMainImplementation"(libs.asm.tree) { exclude(group = "org.ow2.asm", module = "asm") } + "jvmMainImplementation"(libs.dex2jar) { exclude(group = "org.ow2.asm", module = "asm") } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/app/Activity.kt b/androidCompat/src/jvmMain/kotlin/android/app/Activity.kt new file mode 100644 index 00000000000..60d149895cd --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/app/Activity.kt @@ -0,0 +1,41 @@ +package android.app + +import android.content.ContextWrapper +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.view.ContextThemeWrapper +import com.lagradost.cloudstream4.android.DesktopAndroid + +/** + * The one activity extensions see on desktop, from CommonActivity.activity. It has no window, so + * extension screens that need one do nothing. + */ +open class Activity : ContextThemeWrapper() { + private var intent: Intent? = Intent(Intent.ACTION_MAIN) + private var requestedOrientation = -1 + + open fun runOnUiThread(action: Runnable?) { + if (action == null) return + if (Looper.getMainLooper().isCurrentThread()) action.run() else Handler(Looper.getMainLooper()).post(action) + } + + open fun getApplication(): Application = DesktopAndroid.application + open fun getIntent(): Intent? = intent + open fun setIntent(newIntent: Intent?) { + intent = newIntent + } + + open fun finish() {} + open fun recreate() {} + open fun isFinishing(): Boolean = false + open fun isDestroyed(): Boolean = false + open fun getRequestedOrientation(): Int = requestedOrientation + open fun setRequestedOrientation(orientation: Int) { + requestedOrientation = orientation + } +} + +open class Application : ContextWrapper() { + open fun onCreate() {} +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/Context.kt b/androidCompat/src/jvmMain/kotlin/android/content/Context.kt new file mode 100644 index 00000000000..cf97ba1d520 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/Context.kt @@ -0,0 +1,85 @@ +package android.content + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.res.Resources +import android.os.Bundle +import android.os.Looper +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Android's Context with the parts extensions use: settings, folders, the package name and opening + * links. Everything else is missing, so an extension calling it fails with NoSuchMethodError only + * when that code runs. + */ +abstract class Context { + open fun getApplicationContext(): Context = DesktopAndroid.application + + open fun getSharedPreferences(name: String?, mode: Int): SharedPreferences = + DesktopAndroid.sharedPreferences(name ?: "null") + + open fun getResources(): Resources = Resources.getSystem() + open fun getPackageName(): String = DesktopAndroid.PACKAGE_NAME + open fun getPackageManager(): PackageManager = PackageManager.instance + open fun getApplicationInfo(): ApplicationInfo = ApplicationInfo.instance + open fun getMainLooper(): Looper = Looper.getMainLooper() + open fun getClassLoader(): ClassLoader = javaClass.classLoader + + open fun getFilesDir(): File = DesktopAndroid.filesDir.toFile() + open fun getCacheDir(): File = DesktopAndroid.cacheDir.toFile() + open fun getDataDir(): File = DesktopAndroid.filesDir.parent.toFile() + open fun getExternalFilesDir(type: String?): File? = if (type == null) getFilesDir() else File(getFilesDir(), type).apply { mkdirs() } + open fun getExternalCacheDir(): File? = getCacheDir() + open fun getDir(name: String?, mode: Int): File = File(getDataDir(), "app_$name").apply { mkdirs() } + open fun openFileInput(name: String?): FileInputStream = FileInputStream(File(getFilesDir(), name ?: "")) + open fun openFileOutput(name: String?, mode: Int): FileOutputStream = + FileOutputStream(File(getFilesDir(), name ?: ""), mode and MODE_APPEND != 0) + open fun deleteFile(name: String?): Boolean = File(getFilesDir(), name ?: "").delete() + + /** Strings come from the app's resources on Android, which desktop does not have */ + open fun getString(resId: Int): String = "" + open fun getString(resId: Int, vararg formatArgs: Any?): String = "" + open fun getColor(id: Int): Int = 0 + + /** No Android services exist on desktop */ + open fun getSystemService(name: String?): Any? = null + + /** Opens web links in the browser, which is what extensions use this for. Other intents are ignored */ + open fun startActivity(intent: Intent?) { + val url = intent?.getData()?.toString() + if (intent?.getAction() == Intent.ACTION_VIEW && url != null && DesktopAndroid.openUrl(url)) return + println("WARNING Context: Ignored startActivity($intent), desktop has no Android activities") + } + + open fun startActivity(intent: Intent?, options: Bundle?) = startActivity(intent) + open fun sendBroadcast(intent: Intent?) {} + + companion object { + const val MODE_PRIVATE = 0 + const val MODE_WORLD_READABLE = 1 + const val MODE_WORLD_WRITEABLE = 2 + const val MODE_APPEND = 32768 + const val ACTIVITY_SERVICE = "activity" + const val CLIPBOARD_SERVICE = "clipboard" + const val CONNECTIVITY_SERVICE = "connectivity" + const val INPUT_METHOD_SERVICE = "input_method" + const val LAYOUT_INFLATER_SERVICE = "layout_inflater" + const val NOTIFICATION_SERVICE = "notification" + const val UI_MODE_SERVICE = "uimode" + const val WINDOW_SERVICE = "window" + } +} + +open class ContextWrapper(private var base: Context?) : Context() { + /** Generated stub subclasses call the no-argument constructor */ + constructor() : this(null) + + open fun getBaseContext(): Context? = base + + protected open fun attachBaseContext(base: Context?) { + this.base = base + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/Intent.kt b/androidCompat/src/jvmMain/kotlin/android/content/Intent.kt new file mode 100644 index 00000000000..e437decaa3d --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/Intent.kt @@ -0,0 +1,103 @@ +package android.content + +import android.net.Uri +import android.os.Bundle + +/** Android's Intent as a plain holder. Context.startActivity opens ACTION_VIEW web links and ignores the rest */ +open class Intent() { + private var action: String? = null + private var data: Uri? = null + private var type: String? = null + private var flags = 0 + private var component: ComponentName? = null + private var packageName: String? = null + private val extras = Bundle() + + constructor(action: String?) : this() { + this.action = action + } + + constructor(action: String?, uri: Uri?) : this(action) { + data = uri + } + + constructor(packageContext: Context?, cls: Class<*>?) : this() { + component = cls?.let { ComponentName(packageContext?.getPackageName(), it.name) } + } + + constructor(other: Intent?) : this() { + if (other != null) { + action = other.action + data = other.data + type = other.type + flags = other.flags + component = other.component + packageName = other.packageName + extras.putAll(other.extras) + } + } + + open fun getAction(): String? = action + open fun setAction(action: String?): Intent = apply { this.action = action } + open fun getData(): Uri? = data + open fun setData(data: Uri?): Intent = apply { this.data = data } + open fun getType(): String? = type + open fun setType(type: String?): Intent = apply { this.type = type } + open fun setDataAndType(data: Uri?, type: String?): Intent = apply { this.data = data; this.type = type } + open fun getFlags(): Int = flags + open fun setFlags(flags: Int): Intent = apply { this.flags = flags } + open fun addFlags(flags: Int): Intent = apply { this.flags = this.flags or flags } + open fun getComponent(): ComponentName? = component + open fun setComponent(component: ComponentName?): Intent = apply { this.component = component } + open fun getPackage(): String? = packageName + open fun setPackage(packageName: String?): Intent = apply { this.packageName = packageName } + open fun setClassName(packageName: String?, className: String?): Intent = + apply { component = ComponentName(packageName, className) } + + open fun getExtras(): Bundle? = extras + open fun putExtras(extras: Bundle?): Intent = apply { extras?.let(this.extras::putAll) } + open fun hasExtra(name: String?): Boolean = extras.containsKey(name) + open fun putExtra(name: String?, value: String?): Intent = apply { extras.putString(name, value) } + open fun putExtra(name: String?, value: Int): Intent = apply { extras.putInt(name, value) } + open fun putExtra(name: String?, value: Long): Intent = apply { extras.putLong(name, value) } + open fun putExtra(name: String?, value: Boolean): Intent = apply { extras.putBoolean(name, value) } + open fun putExtra(name: String?, value: Float): Intent = apply { extras.putFloat(name, value) } + open fun putExtra(name: String?, value: Double): Intent = apply { extras.putDouble(name, value) } + open fun putExtra(name: String?, value: Bundle?): Intent = apply { extras.putBundle(name, value) } + open fun putExtra(name: String?, value: java.io.Serializable?): Intent = apply { extras.putSerializable(name, value) } + open fun getStringExtra(name: String?): String? = extras.getString(name) + open fun getIntExtra(name: String?, defaultValue: Int): Int = extras.getInt(name, defaultValue) + open fun getLongExtra(name: String?, defaultValue: Long): Long = extras.getLong(name, defaultValue) + open fun getBooleanExtra(name: String?, defaultValue: Boolean): Boolean = extras.getBoolean(name, defaultValue) + + override fun toString(): String = "Intent { act=$action dat=$data cmp=$component }" + + companion object { + const val ACTION_VIEW = "android.intent.action.VIEW" + const val ACTION_SEND = "android.intent.action.SEND" + const val ACTION_MAIN = "android.intent.action.MAIN" + const val ACTION_CHOOSER = "android.intent.action.CHOOSER" + const val EXTRA_TEXT = "android.intent.extra.TEXT" + const val EXTRA_TITLE = "android.intent.extra.TITLE" + const val EXTRA_INTENT = "android.intent.extra.INTENT" + const val CATEGORY_BROWSABLE = "android.intent.category.BROWSABLE" + const val FLAG_ACTIVITY_NEW_TASK = 0x10000000 + const val FLAG_ACTIVITY_CLEAR_TASK = 0x00008000 + const val FLAG_ACTIVITY_CLEAR_TOP = 0x04000000 + + /** Extensions use this to restart the app after a settings change, which desktop ignores */ + @JvmStatic + fun makeRestartActivityTask(mainActivity: ComponentName?): Intent = Intent(ACTION_MAIN).setComponent(mainActivity) + + @JvmStatic + fun createChooser(target: Intent?, title: CharSequence?): Intent = Intent(target) + } +} + +open class ComponentName(private val pkg: String?, private val cls: String?) { + constructor(pkg: Context?, cls: Class<*>?) : this(pkg?.getPackageName(), cls?.name) + + open fun getPackageName(): String? = pkg + open fun getClassName(): String? = cls + override fun toString(): String = "ComponentName($pkg/$cls)" +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/SharedPreferences.kt b/androidCompat/src/jvmMain/kotlin/android/content/SharedPreferences.kt new file mode 100644 index 00000000000..158360237c1 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/SharedPreferences.kt @@ -0,0 +1,33 @@ +package android.content + +/** Android's SharedPreferences, see DesktopSharedPreferences for the implementation */ +interface SharedPreferences { + fun getAll(): Map + fun getString(key: String?, defValue: String?): String? + fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? + fun getInt(key: String?, defValue: Int): Int + fun getLong(key: String?, defValue: Long): Long + fun getFloat(key: String?, defValue: Float): Float + fun getBoolean(key: String?, defValue: Boolean): Boolean + fun contains(key: String?): Boolean + fun edit(): Editor + fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener?) + fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener?) + + interface Editor { + fun putString(key: String?, value: String?): Editor + fun putStringSet(key: String?, values: MutableSet?): Editor + fun putInt(key: String?, value: Int): Editor + fun putLong(key: String?, value: Long): Editor + fun putFloat(key: String?, value: Float): Editor + fun putBoolean(key: String?, value: Boolean): Editor + fun remove(key: String?): Editor + fun clear(): Editor + fun commit(): Boolean + fun apply() + } + + fun interface OnSharedPreferenceChangeListener { + fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/pm/PackageManager.kt b/androidCompat/src/jvmMain/kotlin/android/content/pm/PackageManager.kt new file mode 100644 index 00000000000..cfd7537329a --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/pm/PackageManager.kt @@ -0,0 +1,41 @@ +package android.content.pm + +import android.content.ComponentName +import android.content.Intent +import com.lagradost.cloudstream4.android.DesktopAndroid + +/** Android's PackageManager for the app restart and feature checks extensions do. Desktop has no other packages */ +open class PackageManager { + /** Only the app itself is found, so restart code gets an intent, which startActivity then ignores */ + open fun getLaunchIntentForPackage(packageName: String?): Intent? = + if (packageName == DesktopAndroid.PACKAGE_NAME) { + Intent(Intent.ACTION_MAIN).setComponent(ComponentName(packageName, "$packageName.MainActivity")) + } else null + + open fun hasSystemFeature(name: String?): Boolean = false + + open class NameNotFoundException(name: String?) : Exception(name) { + constructor() : this(null) + } + + companion object { + const val FEATURE_LEANBACK = "android.software.leanback" + const val FEATURE_TOUCHSCREEN = "android.hardware.touchscreen" + const val PERMISSION_GRANTED = 0 + const val PERMISSION_DENIED = -1 + + internal val instance = PackageManager() + } +} + +open class ApplicationInfo { + @JvmField var packageName: String? = DesktopAndroid.PACKAGE_NAME + @JvmField var dataDir: String? = DesktopAndroid.filesDir.parent.toString() + @JvmField var flags = 0 + @JvmField var targetSdkVersion = 35 + @JvmField var minSdkVersion = 21 + + companion object { + internal val instance by lazy { ApplicationInfo() } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/res/Resources.kt b/androidCompat/src/jvmMain/kotlin/android/content/res/Resources.kt new file mode 100644 index 00000000000..8bc358f8aea --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/res/Resources.kt @@ -0,0 +1,59 @@ +package android.content.res + +import android.util.DisplayMetrics + +/** + * Android's Resources for the screen metrics and configuration extensions read. Desktop has no app + * resources, so lookups find nothing: identifiers are 0 and strings are empty. + */ +open class Resources { + private val metrics = DisplayMetrics() + private val configuration = Configuration() + + open fun getDisplayMetrics(): DisplayMetrics = metrics + open fun getConfiguration(): Configuration = configuration + open fun getIdentifier(name: String?, defType: String?, defPackage: String?): Int = 0 + open fun getString(id: Int): String = "" + open fun getString(id: Int, vararg formatArgs: Any?): String = "" + open fun getColor(id: Int): Int = 0 + open fun getDimension(id: Int): Float = 0f + open fun getDimensionPixelSize(id: Int): Int = 0 + open fun getBoolean(id: Int): Boolean = false + open fun getInteger(id: Int): Int = 0 + + open class NotFoundException(name: String?) : RuntimeException(name) { + constructor() : this(null) + } + + companion object { + private val system = Resources() + + @JvmStatic + fun getSystem(): Resources = system + } +} + +open class Configuration { + @JvmField var uiMode = UI_MODE_TYPE_NORMAL or UI_MODE_NIGHT_YES + @JvmField var orientation = ORIENTATION_LANDSCAPE + @JvmField var screenLayout = SCREENLAYOUT_SIZE_LARGE + @JvmField var fontScale = 1f + @JvmField var densityDpi = 160 + @JvmField var screenWidthDp = 1280 + @JvmField var screenHeightDp = 720 + @JvmField var smallestScreenWidthDp = 720 + @JvmField var locale: java.util.Locale = java.util.Locale.getDefault() + + companion object { + const val UI_MODE_TYPE_MASK = 0x0f + const val UI_MODE_TYPE_NORMAL = 0x01 + const val UI_MODE_TYPE_TELEVISION = 0x04 + const val UI_MODE_NIGHT_MASK = 0x30 + const val UI_MODE_NIGHT_NO = 0x10 + const val UI_MODE_NIGHT_YES = 0x20 + const val ORIENTATION_PORTRAIT = 1 + const val ORIENTATION_LANDSCAPE = 2 + const val SCREENLAYOUT_SIZE_MASK = 0x0f + const val SCREENLAYOUT_SIZE_LARGE = 0x03 + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/net/Uri.kt b/androidCompat/src/jvmMain/kotlin/android/net/Uri.kt new file mode 100644 index 00000000000..73cf35e3140 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/net/Uri.kt @@ -0,0 +1,187 @@ +package android.net + +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.charset.StandardCharsets + +/** + * Android's Uri. Like Android, parsing never fails: the string is split with the RFC 3986 regex, + * and getters decode on the way out. + */ +open class Uri private constructor(private val uriString: String) : Comparable { + private val parts by lazy { URI_REGEX.matchEntire(uriString) } + private fun part(group: Int): String? = parts?.groups?.get(group)?.value + + open fun getScheme(): String? = part(2) + open fun getEncodedAuthority(): String? = part(4) + open fun getAuthority(): String? = getEncodedAuthority()?.let { decode(it) } + open fun getEncodedPath(): String? = part(5) + open fun getPath(): String? = getEncodedPath()?.let { decode(it) } + open fun getEncodedQuery(): String? = part(7) + open fun getQuery(): String? = getEncodedQuery()?.let { decode(it) } + open fun getEncodedFragment(): String? = part(9) + open fun getFragment(): String? = getEncodedFragment()?.let { decode(it) } + open fun getSchemeSpecificPart(): String? = uriString.substringAfter(':', uriString).substringBefore('#') + open fun isAbsolute(): Boolean = getScheme() != null + open fun isRelative(): Boolean = !isAbsolute() + open fun isHierarchical(): Boolean = !isAbsolute() || getSchemeSpecificPart()?.startsWith("/") == true + + open fun getUserInfo(): String? = getEncodedAuthority()?.takeIf { '@' in it }?.substringBefore('@')?.let { decode(it) } + + open fun getHost(): String? { + val hostPort = getEncodedAuthority()?.substringAfterLast('@') ?: return null + val host = if (hostPort.startsWith("[")) hostPort.substringBefore(']') + "]" else hostPort.substringBefore(':') + return decode(host)?.ifEmpty { null } + } + + open fun getPort(): Int { + val hostPort = getEncodedAuthority()?.substringAfterLast('@') ?: return -1 + return hostPort.substringAfter(']', hostPort).substringAfter(':', "").toIntOrNull() ?: -1 + } + + open fun getPathSegments(): List = + getEncodedPath()?.split('/')?.filter { it.isNotEmpty() }?.map { decode(it)!! }.orEmpty() + + open fun getLastPathSegment(): String? = getPathSegments().lastOrNull() + + private fun queryPairs(): List> = getEncodedQuery()?.split('&')?.filter { it.isNotEmpty() }?.map { + percentDecode(it.substringBefore('='), plusAsSpace = true) to percentDecode(it.substringAfter('=', ""), plusAsSpace = true) + }.orEmpty() + + /** Like Android, "+" in the query reads as a space */ + open fun getQueryParameter(key: String?): String? = queryPairs().firstOrNull { it.first == key }?.second + open fun getQueryParameters(key: String?): List = queryPairs().filter { it.first == key }.map { it.second } + open fun getQueryParameterNames(): Set = queryPairs().mapTo(LinkedHashSet()) { it.first } + open fun getBooleanQueryParameter(key: String?, defaultValue: Boolean): Boolean = + getQueryParameter(key)?.let { it != "false" && it != "0" } ?: defaultValue + + open fun buildUpon(): Builder = Builder() + .scheme(getScheme()) + .encodedAuthority(getEncodedAuthority()) + .encodedPath(getEncodedPath()) + .encodedQuery(getEncodedQuery()) + .encodedFragment(getEncodedFragment()) + + open fun normalizeScheme(): Uri = + getScheme()?.let { scheme -> parse(scheme.lowercase() + uriString.substring(scheme.length)) } ?: this + + override fun toString(): String = uriString + override fun equals(other: Any?): Boolean = other is Uri && other.uriString == uriString + override fun hashCode(): Int = uriString.hashCode() + override fun compareTo(other: Uri): Int = uriString.compareTo(other.uriString) + + open class Builder { + private var scheme: String? = null + private var authority: String? = null + private var path: String? = null + private var query: String? = null + private var fragment: String? = null + + open fun scheme(scheme: String?): Builder = apply { this.scheme = scheme } + open fun authority(authority: String?): Builder = apply { this.authority = encode(authority, "@:[]") } + open fun encodedAuthority(authority: String?): Builder = apply { this.authority = authority } + open fun path(path: String?): Builder = apply { this.path = encode(path, "/") } + open fun encodedPath(path: String?): Builder = apply { this.path = path } + open fun appendPath(segment: String?): Builder = appendEncodedPath(encode(segment)) + open fun appendEncodedPath(segment: String?): Builder = apply { + if (segment == null) return@apply + val current = path.orEmpty() + path = if (current.endsWith("/")) current + segment.removePrefix("/") else current + "/" + segment.removePrefix("/") + } + + open fun query(query: String?): Builder = apply { this.query = encode(query, "=&") } + open fun encodedQuery(query: String?): Builder = apply { this.query = query } + open fun appendQueryParameter(key: String?, value: String?): Builder = apply { + val pair = encode(key.orEmpty()) + "=" + encode(value.orEmpty()) + query = if (query.isNullOrEmpty()) pair else "$query&$pair" + } + + open fun clearQuery(): Builder = apply { query = null } + open fun fragment(fragment: String?): Builder = apply { this.fragment = encode(fragment) } + open fun encodedFragment(fragment: String?): Builder = apply { this.fragment = fragment } + + open fun build(): Uri = parse(buildString { + scheme?.let { append(it).append(':') } + authority?.let { append("//").append(it) } + path?.let { + if (authority != null && it.isNotEmpty() && !it.startsWith("/")) append('/') + append(it) + } + query?.let { append('?').append(it) } + fragment?.let { append('#').append(it) } + }) + + override fun toString(): String = build().toString() + } + + companion object { + /** RFC 3986 appendix B */ + private val URI_REGEX = Regex("""^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?""", RegexOption.DOT_MATCHES_ALL) + private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-!.~'()*" + + @JvmField + val EMPTY: Uri = Uri("") + + @JvmStatic + fun parse(uriString: String?): Uri = Uri(uriString ?: throw NullPointerException("uriString")) + + @JvmStatic + fun fromFile(file: File?): Uri { + val path = file!!.absolutePath.replace('\\', '/') + return Uri("file://" + encode(if (path.startsWith("/")) path else "/$path", "/:")) + } + + @JvmStatic + fun fromParts(scheme: String?, ssp: String?, fragment: String?): Uri = + Uri(scheme + ":" + encode(ssp, "/:@") + (fragment?.let { "#" + encode(it) } ?: "")) + + @JvmStatic + fun withAppendedPath(baseUri: Uri?, pathSegment: String?): Uri = baseUri!!.buildUpon().appendEncodedPath(pathSegment).build() + + @JvmStatic + fun encode(s: String?): String? = encode(s, null) + + /** Percent-encodes everything but letters, digits, "_-!.~'()*" and the allowed characters */ + @JvmStatic + fun encode(s: String?, allow: String?): String? { + if (s == null) return null + val out = StringBuilder() + for (b in s.toByteArray(StandardCharsets.UTF_8)) { + val c = (b.toInt() and 0xff).toChar() + if (b >= 0 && (c in UNRESERVED || (allow != null && c in allow))) out.append(c) + else out.append('%').append("%02X".format(b.toInt() and 0xff)) + } + return out.toString() + } + + @JvmStatic + fun decode(s: String?): String? = s?.let { percentDecode(it, plusAsSpace = false) } + + /** Like Android's UriCodec, an invalid escape is kept as it is rather than failing */ + private fun percentDecode(s: String, plusAsSpace: Boolean): String { + if ('%' !in s && !(plusAsSpace && '+' in s)) return s + val out = StringBuilder() + val bytes = ByteArrayOutputStream() + fun flush() { + if (bytes.size() > 0) out.append(bytes.toByteArray().toString(StandardCharsets.UTF_8)).also { bytes.reset() } + } + var i = 0 + while (i < s.length) { + val c = s[i] + val hex = if (c == '%' && i + 2 < s.length) s.substring(i + 1, i + 3).takeIf { it.all(::isHexDigit) }?.toInt(16) else null + if (hex != null) { + bytes.write(hex) + i += 3 + } else { + flush() + out.append(if (c == '+' && plusAsSpace) ' ' else c) + i++ + } + } + flush() + return out.toString() + } + + private fun isHexDigit(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/os/Build.kt b/androidCompat/src/jvmMain/kotlin/android/os/Build.kt new file mode 100644 index 00000000000..c94b7f333ce --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/os/Build.kt @@ -0,0 +1,66 @@ +package android.os + +/** Describes a recent Android phone, so extensions take their modern code paths */ +open class Build { + open class VERSION { + companion object { + @JvmField val SDK_INT: Int = 35 + @JvmField val RELEASE: String = "15" + @JvmField val CODENAME: String = "REL" + @JvmField val INCREMENTAL: String = "desktop" + @JvmField val PREVIEW_SDK_INT: Int = 0 + } + } + + open class VERSION_CODES { + companion object { + const val LOLLIPOP = 21 + const val M = 23 + const val N = 24 + const val O = 26 + const val P = 28 + const val Q = 29 + const val R = 30 + const val S = 31 + const val TIRAMISU = 33 + const val UPSIDE_DOWN_CAKE = 34 + const val VANILLA_ICE_CREAM = 35 + } + } + + companion object { + @JvmField val MANUFACTURER: String = "CloudStream" + @JvmField val BRAND: String = "CloudStream" + @JvmField val MODEL: String = "Desktop" + @JvmField val DEVICE: String = "desktop" + @JvmField val PRODUCT: String = "desktop" + @JvmField val HARDWARE: String = "desktop" + @JvmField val BOARD: String = "desktop" + @JvmField val HOST: String = "desktop" + @JvmField val ID: String = "desktop" + @JvmField val TYPE: String = "user" + @JvmField val TAGS: String = "release-keys" + @JvmField val FINGERPRINT: String = "CloudStream/desktop/desktop:15/desktop:user/release-keys" + @JvmField val SUPPORTED_ABIS: Array = arrayOf("x86_64") + @JvmField val UNKNOWN: String = "unknown" + + @JvmStatic + fun getSerial(): String = UNKNOWN + } +} + +class SystemClock private constructor() { + companion object { + private val start = System.nanoTime() + + /** Android counts these from boot, desktop from when the app started */ + @JvmStatic fun uptimeMillis(): Long = (System.nanoTime() - start) / 1_000_000 + @JvmStatic fun elapsedRealtime(): Long = uptimeMillis() + @JvmStatic fun elapsedRealtimeNanos(): Long = System.nanoTime() - start + @JvmStatic fun currentThreadTimeMillis(): Long = uptimeMillis() + @JvmStatic fun sleep(ms: Long) = try { + Thread.sleep(ms) + } catch (_: InterruptedException) { + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/os/Bundle.kt b/androidCompat/src/jvmMain/kotlin/android/os/Bundle.kt new file mode 100644 index 00000000000..ec83e217019 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/os/Bundle.kt @@ -0,0 +1,71 @@ +package android.os + +import java.io.Serializable + +/** Android's Bundle as a map. Extensions use it for fragment arguments and intent extras */ +open class Bundle() { + private val map = LinkedHashMap() + + constructor(capacity: Int) : this() + + constructor(other: Bundle?) : this() { + other?.let(::putAll) + } + + open fun size(): Int = map.size + open fun isEmpty(): Boolean = map.isEmpty() + open fun clear() = map.clear() + open fun containsKey(key: String?): Boolean = map.containsKey(key) + open fun remove(key: String?) { + map.remove(key) + } + + open fun keySet(): Set = map.keys + open fun putAll(bundle: Bundle?) { + bundle?.let { map.putAll(it.map) } + } + + open fun get(key: String?): Any? = map[key] + + private inline fun typed(key: String?): T? = map[key] as? T + + open fun putString(key: String?, value: String?) = map.set(key, value) + open fun getString(key: String?): String? = typed(key) + open fun getString(key: String?, defaultValue: String?): String? = typed(key) ?: defaultValue + open fun putCharSequence(key: String?, value: CharSequence?) = map.set(key, value) + open fun getCharSequence(key: String?): CharSequence? = typed(key) + open fun putInt(key: String?, value: Int) = map.set(key, value) + open fun getInt(key: String?): Int = getInt(key, 0) + open fun getInt(key: String?, defaultValue: Int): Int = typed(key) ?: defaultValue + open fun putLong(key: String?, value: Long) = map.set(key, value) + open fun getLong(key: String?): Long = getLong(key, 0L) + open fun getLong(key: String?, defaultValue: Long): Long = typed(key) ?: defaultValue + open fun putFloat(key: String?, value: Float) = map.set(key, value) + open fun getFloat(key: String?): Float = getFloat(key, 0f) + open fun getFloat(key: String?, defaultValue: Float): Float = typed(key) ?: defaultValue + open fun putDouble(key: String?, value: Double) = map.set(key, value) + open fun getDouble(key: String?): Double = getDouble(key, 0.0) + open fun getDouble(key: String?, defaultValue: Double): Double = typed(key) ?: defaultValue + open fun putBoolean(key: String?, value: Boolean) = map.set(key, value) + open fun getBoolean(key: String?): Boolean = getBoolean(key, false) + open fun getBoolean(key: String?, defaultValue: Boolean): Boolean = typed(key) ?: defaultValue + open fun putBundle(key: String?, value: Bundle?) = map.set(key, value) + open fun getBundle(key: String?): Bundle? = typed(key) + open fun putSerializable(key: String?, value: Serializable?) = map.set(key, value) + open fun getSerializable(key: String?): Serializable? = typed(key) + open fun putStringArrayList(key: String?, value: ArrayList?) = map.set(key, value) + + @Suppress("UNCHECKED_CAST") + open fun getStringArrayList(key: String?): ArrayList? = map[key] as? ArrayList + open fun putStringArray(key: String?, value: Array?) = map.set(key, value) + + @Suppress("UNCHECKED_CAST") + open fun getStringArray(key: String?): Array? = map[key] as? Array + + override fun toString(): String = "Bundle$map" + + companion object { + @JvmField + val EMPTY = Bundle() + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/os/Handler.kt b/androidCompat/src/jvmMain/kotlin/android/os/Handler.kt new file mode 100644 index 00000000000..05da414f556 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/os/Handler.kt @@ -0,0 +1,107 @@ +package android.os + +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * Android's Looper as a single thread that runs posted work in order. The main looper is a thread + * of its own, not the desktop UI thread, because extensions only use it for timers and callbacks. + */ +class Looper private constructor(name: String) { + @Volatile + private var thread: Thread? = null + + internal val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, name).apply { + isDaemon = true + thread = this + } + } + + fun getThread(): Thread? = thread + fun isCurrentThread(): Boolean = Thread.currentThread() == thread + fun quit() = executor.shutdownNow().let { } + fun quitSafely() = executor.shutdown() + + companion object { + private val main = Looper("Android main looper") + private val current = ThreadLocal() + + @JvmStatic + fun getMainLooper(): Looper = main + + /** The looper of this thread: the main looper on its own thread, or one made by prepare() */ + @JvmStatic + fun myLooper(): Looper? = if (main.isCurrentThread()) main else current.get() + + @JvmStatic + fun prepare() { + if (current.get() == null) current.set(Looper("Android looper ${Thread.currentThread().name}")) + } + + @JvmStatic + fun prepareMainLooper() {} + + /** Work runs on the looper's own thread, so there is nothing to wait for here */ + @JvmStatic + fun loop() {} + } +} + +open class Handler(private val looper: Looper) { + /** Android needs a looper on this thread, desktop falls back to the main looper */ + constructor() : this(Looper.myLooper() ?: Looper.getMainLooper()) + + private class Posted(val runnable: Runnable, val token: Any?, val future: ScheduledFuture<*>) + + private val posted = mutableListOf() + + open fun getLooper(): Looper = looper + + open fun post(r: Runnable?): Boolean = postDelayed(r, 0L) + + open fun postDelayed(r: Runnable?, delayMillis: Long): Boolean = postDelayed(r, null, delayMillis) + + open fun postDelayed(r: Runnable?, token: Any?, delayMillis: Long): Boolean { + if (r == null) return false + return try { + synchronized(posted) { + lateinit var entry: Posted + val future = looper.executor.schedule({ + synchronized(posted) { posted.remove(entry) } + try { + r.run() + } catch (t: Throwable) { + println("ERROR Handler: Posted work failed: $t") + t.printStackTrace() + } + }, maxOf(0L, delayMillis), TimeUnit.MILLISECONDS) + entry = Posted(r, token, future) + posted += entry + } + true + } catch (_: java.util.concurrent.RejectedExecutionException) { + false + } + } + + open fun postAtFrontOfQueue(r: Runnable?): Boolean = post(r) + + open fun hasCallbacks(r: Runnable?): Boolean = synchronized(posted) { posted.any { it.runnable == r } } + + open fun removeCallbacks(r: Runnable?) = remove { it.runnable == r } + + open fun removeCallbacks(r: Runnable?, token: Any?) = remove { it.runnable == r && (token == null || it.token == token) } + + /** A null token removes everything, like Android */ + open fun removeCallbacksAndMessages(token: Any?) = remove { token == null || it.token == token } + + private fun remove(match: (Posted) -> Boolean) = synchronized(posted) { + val removed = posted.filter(match) + removed.forEach { it.future.cancel(false) } + posted.removeAll(removed) + Unit + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/text/TextUtils.kt b/androidCompat/src/jvmMain/kotlin/android/text/TextUtils.kt new file mode 100644 index 00000000000..4eae3a6c580 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/text/TextUtils.kt @@ -0,0 +1,26 @@ +package android.text + +class TextUtils private constructor() { + companion object { + @JvmStatic fun isEmpty(str: CharSequence?): Boolean = str.isNullOrEmpty() + @JvmStatic fun isDigitsOnly(str: CharSequence?): Boolean = str!!.all { it.isDigit() } + @JvmStatic fun equals(a: CharSequence?, b: CharSequence?): Boolean = a?.toString() == b?.toString() + @JvmStatic fun join(delimiter: CharSequence?, tokens: Array?): String = tokens!!.joinToString(delimiter.toString()) + @JvmStatic fun join(delimiter: CharSequence?, tokens: Iterable<*>?): String = tokens!!.joinToString(delimiter.toString()) + @JvmStatic fun split(text: String?, expression: String?): Array = + if (text.isNullOrEmpty()) emptyArray() else text.split(Regex(expression!!)).toTypedArray() + @JvmStatic fun getTrimmedLength(s: CharSequence?): Int = s!!.trim().length + + @JvmStatic + fun htmlEncode(s: String?): String = buildString { + for (c in s!!) when (c) { + '<' -> append("<") + '>' -> append(">") + '&' -> append("&") + '\'' -> append("'") + '"' -> append(""") + else -> append(c) + } + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/util/Base64.kt b/androidCompat/src/jvmMain/kotlin/android/util/Base64.kt new file mode 100644 index 00000000000..ecd52f0039e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/util/Base64.kt @@ -0,0 +1,71 @@ +package android.util + +/** + * Android's Base64, which differs from java.util.Base64 in ways extensions depend on: DEFAULT + * encoding wraps lines at 76 characters and ends with a newline, and decoding skips characters + * outside the alphabet (such as whitespace) and accepts missing padding. + */ +class Base64 private constructor() { + companion object { + const val DEFAULT = 0 + const val NO_PADDING = 1 + const val NO_WRAP = 2 + const val CRLF = 4 + const val URL_SAFE = 8 + const val NO_CLOSE = 16 + + private const val LINE_LENGTH = 76 + + @JvmStatic + fun decode(str: String?, flags: Int): ByteArray = decode(str!!.toByteArray(Charsets.US_ASCII), flags) + + @JvmStatic + fun decode(input: ByteArray?, flags: Int): ByteArray = decode(input, 0, input!!.size, flags) + + @JvmStatic + fun decode(input: ByteArray?, offset: Int, len: Int, flags: Int): ByteArray { + val urlSafe = flags and URL_SAFE != 0 + val data = StringBuilder(len) + var padding = false + for (i in offset until offset + len) { + val c = (input!![i].toInt() and 0xff).toChar() + when { + c == '=' -> padding = true + c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || + (urlSafe && (c == '-' || c == '_')) || (!urlSafe && (c == '+' || c == '/')) -> { + if (padding) throw IllegalArgumentException("bad base-64") + data.append(if (c == '-') '+' else if (c == '_') '/' else c) + } + // Android skips every other character + } + } + if (data.length % 4 == 1) throw IllegalArgumentException("bad base-64") + while (data.length % 4 != 0) data.append('=') + return java.util.Base64.getDecoder().decode(data.toString()) + } + + @JvmStatic + fun encodeToString(input: ByteArray?, flags: Int): String = String(encode(input, flags), Charsets.US_ASCII) + + @JvmStatic + fun encodeToString(input: ByteArray?, offset: Int, len: Int, flags: Int): String = + String(encode(input, offset, len, flags), Charsets.US_ASCII) + + @JvmStatic + fun encode(input: ByteArray?, flags: Int): ByteArray = encode(input, 0, input!!.size, flags) + + @JvmStatic + fun encode(input: ByteArray?, offset: Int, len: Int, flags: Int): ByteArray { + var encoder = if (flags and URL_SAFE != 0) java.util.Base64.getUrlEncoder() else java.util.Base64.getEncoder() + if (flags and NO_PADDING != 0) encoder = encoder.withoutPadding() + val text = encoder.encodeToString(input!!.copyOfRange(offset, offset + len)) + if (flags and NO_WRAP != 0) return text.toByteArray(Charsets.US_ASCII) + val newline = if (flags and CRLF != 0) "\r\n" else "\n" + val wrapped = StringBuilder() + for (start in text.indices step LINE_LENGTH) { + wrapped.append(text, start, minOf(text.length, start + LINE_LENGTH)).append(newline) + } + return wrapped.toString().toByteArray(Charsets.US_ASCII) + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/util/DisplayMetrics.kt b/androidCompat/src/jvmMain/kotlin/android/util/DisplayMetrics.kt new file mode 100644 index 00000000000..8883deecad3 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/util/DisplayMetrics.kt @@ -0,0 +1,18 @@ +package android.util + +/** A 1280x720 screen at Android's baseline density, so dp and px are the same */ +open class DisplayMetrics { + @JvmField var widthPixels = 1280 + @JvmField var heightPixels = 720 + @JvmField var density = 1f + @JvmField var densityDpi = 160 + @JvmField var scaledDensity = 1f + @JvmField var xdpi = 160f + @JvmField var ydpi = 160f + + open fun setToDefaults() {} + + companion object { + const val DENSITY_DEFAULT = 160 + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/util/Log.kt b/androidCompat/src/jvmMain/kotlin/android/util/Log.kt new file mode 100644 index 00000000000..35dbee4c95e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/util/Log.kt @@ -0,0 +1,47 @@ +package android.util + +import java.io.PrintWriter +import java.io.StringWriter + +/** Android's Log, printed in the app's "LEVEL tag: message" format */ +class Log private constructor() { + companion object { + const val VERBOSE = 2 + const val DEBUG = 3 + const val INFO = 4 + const val WARN = 5 + const val ERROR = 6 + const val ASSERT = 7 + + private fun write(level: String, tag: String?, msg: String?, tr: Throwable? = null): Int { + val line = "$level $tag: $msg" + (tr?.let { "\n" + getStackTraceString(it) } ?: "") + kotlin.io.println(line) + return line.length + } + + @JvmStatic fun v(tag: String?, msg: String?): Int = write("VERBOSE", tag, msg) + @JvmStatic fun v(tag: String?, msg: String?, tr: Throwable?): Int = write("VERBOSE", tag, msg, tr) + @JvmStatic fun d(tag: String?, msg: String?): Int = write("DEBUG", tag, msg) + @JvmStatic fun d(tag: String?, msg: String?, tr: Throwable?): Int = write("DEBUG", tag, msg, tr) + @JvmStatic fun i(tag: String?, msg: String?): Int = write("INFO", tag, msg) + @JvmStatic fun i(tag: String?, msg: String?, tr: Throwable?): Int = write("INFO", tag, msg, tr) + @JvmStatic fun w(tag: String?, msg: String?): Int = write("WARNING", tag, msg) + @JvmStatic fun w(tag: String?, msg: String?, tr: Throwable?): Int = write("WARNING", tag, msg, tr) + @JvmStatic fun w(tag: String?, tr: Throwable?): Int = write("WARNING", tag, "", tr) + @JvmStatic fun e(tag: String?, msg: String?): Int = write("ERROR", tag, msg) + @JvmStatic fun e(tag: String?, msg: String?, tr: Throwable?): Int = write("ERROR", tag, msg, tr) + @JvmStatic fun wtf(tag: String?, msg: String?): Int = write("ERROR", tag, msg) + @JvmStatic fun wtf(tag: String?, tr: Throwable?): Int = write("ERROR", tag, "", tr) + @JvmStatic fun wtf(tag: String?, msg: String?, tr: Throwable?): Int = write("ERROR", tag, msg, tr) + @JvmStatic fun println(priority: Int, tag: String?, msg: String?): Int = write("LOG$priority", tag, msg) + @JvmStatic fun isLoggable(tag: String?, level: Int): Boolean = level >= INFO + + @JvmStatic + fun getStackTraceString(tr: Throwable?): String { + if (tr == null) return "" + val out = StringWriter() + tr.printStackTrace(PrintWriter(out)) + return out.toString() + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/view/ContextThemeWrapper.kt b/androidCompat/src/jvmMain/kotlin/android/view/ContextThemeWrapper.kt new file mode 100644 index 00000000000..5d50eecfe16 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/view/ContextThemeWrapper.kt @@ -0,0 +1,11 @@ +package android.view + +import android.content.Context +import android.content.ContextWrapper + +open class ContextThemeWrapper(base: Context?) : ContextWrapper(base) { + /** Generated stub subclasses call the no-argument constructor */ + constructor() : this(null) + + constructor(base: Context?, themeResId: Int) : this(base) +} diff --git a/androidCompat/src/jvmMain/kotlin/android/widget/Toast.kt b/androidCompat/src/jvmMain/kotlin/android/widget/Toast.kt new file mode 100644 index 00000000000..3f10313402e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/widget/Toast.kt @@ -0,0 +1,53 @@ +package android.widget + +import android.content.Context +import com.lagradost.cloudstream4.android.DesktopAndroid + +/** Android's Toast, shown as a message at the bottom of the app window */ +open class Toast(private val context: Context?) { + private var text: CharSequence? = null + private var length = LENGTH_SHORT + + open fun show() { + text?.toString()?.takeIf { it.isNotBlank() }?.let(DesktopAndroid::showToast) + } + + open fun cancel() {} + + open fun setText(s: CharSequence?) { + text = s + } + + open fun setText(resId: Int) { + text = context?.getString(resId) + } + + open fun getDuration(): Int = length + + open fun setDuration(duration: Int) { + length = duration + } + + open fun setGravity(gravity: Int, xOffset: Int, yOffset: Int) {} + + open fun setMargin(horizontalMargin: Float, verticalMargin: Float) {} + + companion object { + const val LENGTH_SHORT = 0 + const val LENGTH_LONG = 1 + + @JvmStatic + fun makeText(context: Context?, text: CharSequence?, duration: Int): Toast = + Toast(context).apply { + setText(text) + setDuration(duration) + } + + @JvmStatic + fun makeText(context: Context?, resId: Int, duration: Int): Toast = + Toast(context).apply { + setText(resId) + setDuration(duration) + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/androidx/activity/ComponentActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/activity/ComponentActivity.kt new file mode 100644 index 00000000000..f1f10807b2f --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/activity/ComponentActivity.kt @@ -0,0 +1,4 @@ +package androidx.activity + +/** See androidx.core.app.ComponentActivity */ +open class ComponentActivity : androidx.core.app.ComponentActivity() diff --git a/androidCompat/src/jvmMain/kotlin/androidx/appcompat/app/AppCompatActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/appcompat/app/AppCompatActivity.kt new file mode 100644 index 00000000000..4940bbe1e7f --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/appcompat/app/AppCompatActivity.kt @@ -0,0 +1,6 @@ +package androidx.appcompat.app + +import androidx.fragment.app.FragmentActivity + +/** The desktop app's activity, as Android extensions expect it. See androidx.core.app.ComponentActivity */ +open class AppCompatActivity : FragmentActivity() diff --git a/androidCompat/src/jvmMain/kotlin/androidx/core/app/ComponentActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/core/app/ComponentActivity.kt new file mode 100644 index 00000000000..e6c143bc2d9 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/core/app/ComponentActivity.kt @@ -0,0 +1,11 @@ +package androidx.core.app + +import android.app.Activity + +/** + * Real desktop classes for the activity chain Android extensions cast the app's activity to, often + * `context as AppCompatActivity` in their load. Stubs generated inside the extension would be other + * classes than the app's activity, so the cast failed. Members desktop lacks are redirected by + * MissingMembers like on any desktop stand-in. + */ +open class ComponentActivity : Activity() diff --git a/androidCompat/src/jvmMain/kotlin/androidx/fragment/app/FragmentActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/fragment/app/FragmentActivity.kt new file mode 100644 index 00000000000..d45119236b5 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/fragment/app/FragmentActivity.kt @@ -0,0 +1,6 @@ +package androidx.fragment.app + +import androidx.activity.ComponentActivity + +/** See androidx.core.app.ComponentActivity */ +open class FragmentActivity : ComponentActivity() diff --git a/androidCompat/src/jvmMain/kotlin/androidx/preference/PreferenceManager.kt b/androidCompat/src/jvmMain/kotlin/androidx/preference/PreferenceManager.kt new file mode 100644 index 00000000000..012c7dcda3e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/preference/PreferenceManager.kt @@ -0,0 +1,17 @@ +package androidx.preference + +import android.content.Context +import android.content.SharedPreferences + +/** Only the default preferences lookup, which extensions call outside their settings screens too */ +open class PreferenceManager { + companion object { + @JvmStatic + fun getDefaultSharedPreferencesName(context: Context?): String = (context?.getPackageName() ?: "app") + "_preferences" + + @JvmStatic + fun getDefaultSharedPreferences(context: Context?): SharedPreferences = + (context ?: com.lagradost.cloudstream4.android.DesktopAndroid.application) + .getSharedPreferences(getDefaultSharedPreferencesName(context), Context.MODE_PRIVATE) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt new file mode 100644 index 00000000000..015866e6f85 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,106 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +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.cloudstream4.android.DesktopAndroid + +/** The Android app's Application, for the context and settings helpers in its companion */ +class CloudStreamApp : Application() { + companion object { + /** Use to get Activity from Context. */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> { + val base = getBaseContext() ?: return null + if (base === this) null else base.getActivity() + } + else -> null + } + } + + val context: Context? get() = DesktopAndroid.application + + 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) + } + + /** Desktop has no WebView, so the link always opens in the browser */ + fun openBrowser(url: String, activity: Activity?) { + DesktopAndroid.openUrl(url) + } + } +} + +/** Deprecated alias for CloudStreamApp that older extensions still use */ +class AcraApplication { + companion object { + val context get() = CloudStreamApp.context + + fun removeKeys(folder: String): Int? = CloudStreamApp.removeKeys(folder) + + fun setKey(path: String, value: T) = CloudStreamApp.setKey(path, value) + + fun setKey(folder: String, path: String, value: T) = CloudStreamApp.setKey(folder, path, value) + + inline fun getKey(path: String, defVal: T?): T? = CloudStreamApp.getKey(path, defVal) + + inline fun getKey(path: String): T? = CloudStreamApp.getKey(path) + + inline fun getKey(folder: String, path: String): T? = CloudStreamApp.getKey(folder, path) + + inline fun getKey(folder: String, path: String, defVal: T?): T? = + CloudStreamApp.getKey(folder, path, defVal) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt new file mode 100644 index 00000000000..b06d2d1ed9e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.content.res.Resources +import android.util.DisplayMetrics +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlin.math.max +import kotlin.math.min + +/** The Android app's CommonActivity, for the activity and toasts extensions use */ +object CommonActivity { + val activity: Activity? get() = DesktopAndroid.activity + + fun setActivityInstance(newActivity: Activity?) {} + + val displayMetrics: DisplayMetrics = Resources.getSystem().getDisplayMetrics() + + val screenWidth: Int get() = max(displayMetrics.widthPixels, displayMetrics.heightPixels) + val screenHeight: Int get() = min(displayMetrics.widthPixels, displayMetrics.heightPixels) + val screenWidthWithOrientation: Int get() = displayMetrics.widthPixels + val screenHeightWithOrientation: Int get() = displayMetrics.heightPixels + + var isPipDesired: Boolean = false + var isInPIPMode: Boolean = false + var appliedTheme: Int = 0 + var appliedColor: Int = 0 + + const val TAG = "COMPACT" + + /** Desktop has no string resources, so these show nothing */ + fun showToast(message: Int, duration: Int? = null) {} + + fun showToast(message: String?, duration: Int? = null) { + message?.let(DesktopAndroid::showToast) + } + + fun showToast(act: Activity?, message: Int, duration: Int? = null) {} + + fun showToast(act: Activity?, message: String?, duration: Int? = null) { + message?.let(DesktopAndroid::showToast) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt new file mode 100644 index 00000000000..a2d2ab4f738 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -0,0 +1,22 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import com.lagradost.cloudstream3.utils.Event + +/** + * The Android app's MainActivity, for the events in its companion that extensions subscribe to. + * The desktop app fires afterPluginsLoadedEvent and mainPluginsLoadedEvent after loading extensions. + */ +class MainActivity : Activity() { + companion object { + const val TAG = "MAINACT" + + val afterPluginsLoadedEvent = Event() + val mainPluginsLoadedEvent = Event() + val afterRepositoryLoadedEvent = Event() + val bookmarksUpdatedEvent = Event() + val reloadHomeEvent = Event() + val reloadLibraryEvent = Event() + val reloadAccountEvent = Event() + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt new file mode 100644 index 00000000000..4408d1b49fa --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -0,0 +1,118 @@ +package com.lagradost.cloudstream3.network + +import com.lagradost.cloudstream3.app +import com.lagradost.nicehttp.Requests.Companion.await +import com.lagradost.nicehttp.cookies +import kotlinx.coroutines.runBlocking +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import java.net.URI + +/** + * The Android app's CloudflareKiller. A response from Cloudflare refusing the request (403 or 503) + * opens the page in the desktop's browser ([DesktopWebView]) until Cloudflare's check passes and + * gives its cf_clearance cookie; the request is then sent again with the browser's cookies and user + * agent. A site that still refuses it, because it only trusts the browser itself, gets the request + * from inside the browser. Without a browser, requests go through unchanged. + */ +class CloudflareKiller : Interceptor { + companion object { + const val TAG = "CloudflareKiller" + private val ERROR_CODES = listOf(403, 503) + private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare") + + fun parseCookieMap(cookie: String): Map { + return cookie.split(";").associate { + val split = it.split("=") + (split.getOrNull(0)?.trim() ?: "") to (split.getOrNull(1)?.trim() ?: "") + }.filter { it.key.isNotBlank() && it.value.isNotBlank() } + } + } + + val savedCookies: MutableMap> = mutableMapOf() + + /** Gets the headers with the saved cookies, with the browser's user agent */ + fun getCookieHeaders(url: String): Headers { + val userAgentHeaders = WebViewResolver.webViewUserAgent?.let { + mapOf("user-agent" to it) + } ?: emptyMap() + + return getHeaders(userAgentHeaders, savedCookies[URI(url).host] ?: emptyMap()) + } + + override fun intercept(chain: Interceptor.Chain): Response = runBlocking { + val request = chain.request() + + when (val cookies = savedCookies[request.url.host]) { + null -> { + val response = chain.proceed(request) + if (!isRefused(response)) { + return@runBlocking response + } else { + response.close() + bypassCloudflare(request)?.let { + println("INFO $TAG: Succeeded bypassing cloudflare: ${request.url}") + return@runBlocking it + } + } + } + + else -> return@runBlocking proceed(request, cookies) + } + + println("WARN $TAG: Failed cloudflare at: ${request.url}") + return@runBlocking chain.proceed(request) + } + + /** + * Returns true if the cf cookies were found in the browser. Also saves the cookies. + */ + private fun trySolveWithSavedCookies(request: Request): Boolean { + val cookie = runCatching { DesktopWebView.engine?.cookies(request.url.toString()) }.getOrNull() ?: return false + return cookie.contains("cf_clearance").also { solved -> + if (solved) savedCookies[request.url.host] = parseCookieMap(cookie) + } + } + + private suspend fun proceed(request: Request, cookies: Map): Response { + val userAgentMap = WebViewResolver.webViewUserAgent?.let { + mapOf("user-agent" to it) + } ?: emptyMap() + + val headers = getHeaders(request.headers.toMap() + userAgentMap, cookies + request.cookies) + val response = app.baseClient.newCall( + request.newBuilder() + .headers(headers) + .build() + ).await() + if (!isRefused(response)) return response + val fetched = runCatching { DesktopWebView.engine?.fetch(request) }.getOrNull() ?: return response + response.close() + return fetched + } + + private fun isRefused(response: Response) = response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES + + private suspend fun bypassCloudflare(request: Request): Response? { + if (DesktopWebView.engine == null) return null + if (!trySolveWithSavedCookies(request)) { + println("INFO $TAG: Loading the browser to solve cloudflare for ${request.url}") + WebViewResolver( + // Never exit based on url + Regex(".^"), + // Cloudflare needs the browser's own user agent + userAgent = null, + useOkhttp = false, + // Match every url for the requestCallBack + additionalUrls = listOf(Regex(".")) + ).resolveUsingWebView(request.url.toString()) { + trySolveWithSavedCookies(request) + } + } + + val cookies = savedCookies[request.url.host] ?: return null + return proceed(request, cookies) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/RequestsHelper.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/RequestsHelper.kt new file mode 100644 index 00000000000..646e469766a --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -0,0 +1,23 @@ +package com.lagradost.cloudstream3.network + +import com.lagradost.cloudstream3.USER_AGENT +import okhttp3.Headers +import okhttp3.Headers.Companion.toHeaders + +// From the Android app's RequestsHelper.kt, which extensions call for CloudflareKiller headers + +private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) + +/** Set headers > Set cookies > Default headers > Default Cookies */ +fun getHeaders( + headers: Map, + cookie: Map +): Headers { + val cookieMap = + if (cookie.isNotEmpty()) mapOf( + "Cookie" to cookie.entries.joinToString(" ") { + "${it.key}=${it.value};" + }) else mapOf() + val tempHeaders = (DEFAULT_HEADERS + headers + cookieMap) + return tempHeaders.toHeaders() +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt new file mode 100644 index 00000000000..fc096835283 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -0,0 +1,26 @@ +package com.lagradost.cloudstream3.plugins + +import android.content.Context +import android.content.res.Resources + +/** + * The Android app's Plugin, which Android-only extensions extend. Same members as the app, except + * registerVideoClickAction: video click actions open other Android apps, which desktop cannot. + */ +abstract class Plugin : BasePlugin() { + /** + * Called when your Plugin is loaded + * @param context Context + */ + @Throws(Throwable::class) + open fun load(context: Context) { + // If not overridden by an extension then try the cross-platform load() + load() + } + + /** Extension resources are Android resources, which desktop cannot read, so this stays null */ + var resources: Resources? = null + + /** Settings screens are Android views, so desktop does not show this button */ + var openSettings: ((context: Context) -> Unit)? = null +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt new file mode 100644 index 00000000000..c2714ae541e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.plugins + +/** The Android app's record of an installed plugin */ +data class PluginData( + val internalName: String, + val url: String?, + val isOnline: Boolean, + val filePath: String, + val version: Int, +) + +/** + * The parts of the Android app's PluginManager extensions call, mostly to find their own entry. + * The desktop plugin manager fills in [installed]. + */ +object PluginManager { + @Volatile + var installed: () -> List = { emptyList() } + + /** Unloads a plugin by its file, set by the desktop plugin manager */ + @Volatile + var unloader: (String) -> Unit = {} + + fun getPluginsOnline(): Array = installed().toTypedArray() + + /** Desktop has no local plugin folder */ + fun getPluginsLocal(): Array = emptyArray() + + fun unloadPlugin(absolutePath: String) = unloader(absolutePath) +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt new file mode 100644 index 00000000000..eb76552afae --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt @@ -0,0 +1,199 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +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 + +// The Android app's DataStore, which extensions use to keep their settings. Copied from the app, +// with the androidx edit {} helper written out + +const val PREFERENCES_NAME = "rebuild_preference" + +class PreferenceDelegate( + val key: String, val default: T +) { + private val klass: KClass = default::class + + // simple cache to make it not get the key every time it is accessed, however this requires + // that ONLY this changes the key + private var cache: T? = null + + operator fun getValue(self: Any?, property: KProperty<*>) = + cache ?: getKeyClass(key, klass.java).also { newCache -> cache = newCache } ?: default + + operator fun setValue( + self: Any?, + property: KProperty<*>, + t: T? + ) { + cache = t + if (t == null) { + removeKey(key) + } else { + setKeyClass(key, t) + } + } +} + +/** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ +data class Editor( + val editor: SharedPreferences.Editor +) { + /** Always remember to call apply after */ + fun setKeyRaw(path: String, value: T) { + @Suppress("UNCHECKED_CAST") + if (isStringSet(value)) { + editor.putStringSet(path, (value as Set).toMutableSet()) + } else { + when (value) { + is Boolean -> editor.putBoolean(path, value) + is Int -> editor.putInt(path, value) + is String -> editor.putString(path, value) + is Float -> editor.putFloat(path, value) + is Long -> editor.putLong(path, value) + } + } + } + + private fun isStringSet(value: Any?): Boolean { + if (value is Set<*>) { + return value.filterIsInstance().size == value.size + } + return false + } + + fun apply() { + editor.apply() + } +} + +object DataStore { + val mapper = com.lagradost.cloudstream3.mapper + + private fun getPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + } + + fun Context.getSharedPrefs(): SharedPreferences { + return getPreferences(this) + } + + fun getFolderName(folder: String, path: String): String { + return "${folder}/${path}" + } + + fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { + val editor: SharedPreferences.Editor = + if (isEditingAppSettings) context.getDefaultSharedPrefs() + .edit() else context.getSharedPrefs().edit() + return Editor(editor) + } + + fun Context.getDefaultSharedPrefs(): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(this) + } + + fun Context.getKeys(folder: String): List { + // Ensure that the folder ends with "/" to prevent matching with other folders + val fixedFolder = folder.trimEnd('/') + "/" + return this.getSharedPrefs().getAll().keys.filter { it.startsWith(fixedFolder) } + } + + fun Context.removeKey(folder: String, path: String) { + removeKey(getFolderName(folder, path)) + } + + fun Context.containsKey(folder: String, path: String): Boolean { + return containsKey(getFolderName(folder, path)) + } + + fun Context.containsKey(path: String): Boolean { + val prefs = getSharedPrefs() + return prefs.contains(path) + } + + fun Context.removeKey(path: String) { + try { + val prefs = getSharedPrefs() + if (prefs.contains(path)) { + prefs.edit().remove(path).apply() + } + } catch (e: Exception) { + logError(e) + } + } + + fun Context.removeKeys(folder: String): Int { + val keys = getKeys("$folder/") + try { + val editor = getSharedPrefs().edit() + keys.forEach { value -> + editor.remove(value) + } + editor.apply() + return keys.size + } catch (e: Exception) { + logError(e) + return 0 + } + } + + fun Context.setKey(path: String, value: T) { + try { + getSharedPrefs().edit().putString(path, value?.toJsonLiteral()).apply() + } catch (e: Exception) { + logError(e) + } + } + + fun Context.getKey(path: String, valueType: Class): T? { + return try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + parseJson(json, valueType.kotlin) + } catch (_: Exception) { + null + } + } + + fun Context.setKey(folder: String, path: String, value: T) { + setKey(getFolderName(folder, path), value) + } + + inline fun String.toKotlinObject(): T { + return parseJson(this) + } + + fun String.toKotlinObject(valueType: Class): T { + return parseJson(this, valueType.kotlin) + } + + // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR + inline fun Context.getKey(path: String, defVal: T?): T? { + return try { + val json: String = getSharedPrefs().getString(path, null) ?: return defVal + parseJson(json) + } catch (_: Exception) { + null + } + } + + inline fun Context.getKey(path: String): T? { + return getKey(path, null) + } + + inline fun Context.getKey(folder: String, path: String): T? { + return getKey(getFolderName(folder, path), null) + } + + inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { + return getKey(getFolderName(folder, path), defVal) ?: defVal + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt new file mode 100644 index 00000000000..cb16f920be6 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt @@ -0,0 +1,52 @@ +package com.lagradost.cloudstream3.utils + +/** Copied from the Android app, extensions subscribe to MainActivity's events with it */ +class Event { + private val observers = mutableSetOf<(T) -> Unit>() + + val size: Int get() = observers.size + + operator fun plusAssign(observer: (T) -> Unit) { + synchronized(observers) { + observers.add(observer) + } + } + + operator fun minusAssign(observer: (T) -> Unit) { + synchronized(observers) { + observers.remove(observer) + } + } + + operator fun invoke(value: T) { + synchronized(observers) { + for (observer in observers) + observer(value) + } + } +} + +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/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt new file mode 100644 index 00000000000..cfb7d541440 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt @@ -0,0 +1,69 @@ +package com.lagradost.cloudstream4.android + +import java.net.URLClassLoader +import java.nio.file.Path +import java.security.CodeSource +import java.security.cert.Certificate +import java.util.jar.JarFile + +/** + * Loads an extension jar. When the extension refers to an Android class desktop has no version of, + * such as a widget for its settings screen, an empty stub of that class is generated instead, with + * the members the extension uses. The JVM only resolves classes when code using them runs, so the + * parts of the extension that search and load links work, and stubbed parts do nothing. + * + * Calls to members a desktop version of an Android class lacks are redirected as the extension's + * classes load, see MissingMembers. + */ +class AndroidPluginClassLoader( + private val jar: Path, + parent: ClassLoader, + /** Stubs and redirects what desktop lacks, for Android plugins. Off for desktop jars, which fail plainly */ + private val stubMissing: Boolean = true, +) : URLClassLoader(arrayOf(jar.toUri().toURL()), parent) { + /** Read from the jar the first time a class loads */ + private val model by lazy { StubModel.scan(jar) } + private val generator by lazy { StubGenerator(model, parent) } + private val missingMembers by lazy { MissingMembers(model, generator, parent) } + private val codeSource = CodeSource(jar.toUri().toURL(), null as Array?) + + /** Classes are read from this rather than through URLs, whose cached jar files would keep the jar locked on Windows */ + private val jarFile = JarFile(jar.toFile()) + + private val stubbed = mutableSetOf() + + /** Names of the classes generated as stubs so far, to tell the user what the extension is missing */ + val stubbedClasses: Set get() = synchronized(stubbed) { stubbed.toSet() } + + override fun findClass(name: String): Class<*> { + if (stubMissing) missingMembers.helperClass(name)?.let { return define(name, it) } + + val internalName = name.replace('.', '/') + val entry = synchronized(jarFile) { jarFile.getJarEntry("$internalName.class") } + if (entry != null) { + val bytes = synchronized(jarFile) { jarFile.getInputStream(entry).use { it.readBytes() } } + return define(name, if (stubMissing) missingMembers.rewrite(name, bytes) else bytes) + } + + if (!stubMissing || !StubHierarchy.isStubbed(internalName)) throw ClassNotFoundException(name) + val bytes = generator.generate(internalName) + synchronized(stubbed) { stubbed += name } + return define(name, bytes) + } + + override fun close() { + try { + super.close() + } finally { + synchronized(jarFile) { jarFile.close() } + } + } + + private fun define(name: String, bytes: ByteArray): Class<*> { + val pkg = name.substringBeforeLast('.', "") + if (pkg.isNotEmpty() && getDefinedPackage(pkg) == null) { + runCatching { definePackage(pkg, null, null, null, null, null, null, null) } + } + return defineClass(name, bytes, 0, bytes.size, codeSource) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt new file mode 100644 index 00000000000..7c33f84d216 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt @@ -0,0 +1,64 @@ +package com.lagradost.cloudstream4.android + +import android.app.Activity +import android.app.Application +import android.content.SharedPreferences +import java.awt.Desktop +import java.net.URI +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.createDirectories + +/** + * The desktop side of the Android stand-ins: where extensions keep their files and settings, the + * one application and activity they see, and how their toasts reach the app. + */ +object DesktopAndroid { + const val PACKAGE_NAME = "com.lagradost.cloudstream3" + + @Volatile + private var root: Path = Path.of(System.getProperty("java.io.tmpdir"), "cloudstream-android") + + /** Sets the folder extensions keep their files and settings in. Call before loading extensions */ + fun init(dataDir: Path) { + if (dataDir == root) return + root = dataDir + // Settings already opened belong to the old folder + preferences.clear() + } + + val filesDir: Path get() = root.resolve("files").also { it.createDirectories() } + val cacheDir: Path get() = root.resolve("cache").also { it.createDirectories() } + private val prefsDir: Path get() = root.resolve("shared_prefs").also { it.createDirectories() } + + private val preferences = ConcurrentHashMap() + + /** The same instance for the same name, like Android, so listeners and unsaved edits are shared */ + fun sharedPreferences(name: String): SharedPreferences = + preferences.computeIfAbsent(name) { DesktopSharedPreferences(prefsDir.resolve("${safeFileName(it)}.json")) } + + val application: Application by lazy { Application() } + /** An AppCompatActivity, which extensions often cast it to */ + val activity: Activity by lazy { androidx.appcompat.app.AppCompatActivity() } + + /** Shows an extension's toast in the app. Without one, toasts are only logged */ + @Volatile + var toastHandler: ((String) -> Unit)? = null + + fun showToast(message: String) { + // Android shows nothing for an empty toast + if (message.isBlank()) return + println("INFO Extension toast: $message") + toastHandler?.invoke(message) + } + + /** Opens a web link the way Android's ACTION_VIEW would. Returns false when nothing could open it */ + fun openUrl(url: String): Boolean = runCatching { + if (!url.startsWith("http://") && !url.startsWith("https://")) return false + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) return false + Desktop.getDesktop().browse(URI(url)) + true + }.getOrDefault(false) + + private fun safeFileName(name: String) = name.map { if (it.isLetterOrDigit() || it in "._-") it else '_' }.joinToString("") +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopSharedPreferences.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopSharedPreferences.kt new file mode 100644 index 00000000000..0ed207b71bd --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopSharedPreferences.kt @@ -0,0 +1,153 @@ +package com.lagradost.cloudstream4.android + +import android.content.SharedPreferences +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * SharedPreferences kept in a JSON file. Each value is stored with its type, because Android's + * getters throw ClassCastException for the wrong type and extensions rely on Int staying Int. + */ +internal class DesktopSharedPreferences(private val file: Path) : SharedPreferences { + private val values: MutableMap = read() + private val listeners = CopyOnWriteArrayList() + + override fun getAll(): Map = synchronized(values) { values.toMap() } + + override fun getString(key: String?, defValue: String?): String? = get(key) as String? ?: defValue + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + (get(key) as Set?)?.toMutableSet() ?: defValues + + override fun getInt(key: String?, defValue: Int): Int = get(key) as Int? ?: defValue + override fun getLong(key: String?, defValue: Long): Long = get(key) as Long? ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = get(key) as Float? ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = get(key) as Boolean? ?: defValue + override fun contains(key: String?): Boolean = synchronized(values) { values.containsKey(key) } + + private fun get(key: String?): Any? = synchronized(values) { values[key] } + + override fun edit(): SharedPreferences.Editor = EditorImpl() + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listener?.let(listeners::addIfAbsent) + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listeners.remove(listener) + } + + private inner class EditorImpl : SharedPreferences.Editor { + private val changes = mutableMapOf() + private var clear = false + + private fun put(key: String?, value: Any?) = apply { if (key != null) synchronized(changes) { changes[key] = value } } + + override fun putString(key: String?, value: String?) = put(key, value) + override fun putStringSet(key: String?, values: MutableSet?) = put(key, values?.toSet()) + override fun putInt(key: String?, value: Int) = put(key, value) + override fun putLong(key: String?, value: Long) = put(key, value) + override fun putFloat(key: String?, value: Float) = put(key, value) + override fun putBoolean(key: String?, value: Boolean) = put(key, value) + override fun remove(key: String?) = put(key, null) + override fun clear() = apply { clear = true } + + override fun commit(): Boolean { + val changed = applyToMemory() + return runCatching { write() }.onFailure { println("ERROR SharedPreferences: Could not save $file: $it") }.isSuccess + .also { notify(changed) } + } + + override fun apply() { + val changed = applyToMemory() + writer.execute { runCatching { write() }.onFailure { println("ERROR SharedPreferences: Could not save $file: $it") } } + notify(changed) + } + + private fun applyToMemory(): List = synchronized(values) { + val changed = mutableListOf() + if (clear) { + changed += values.keys + values.clear() + } + synchronized(changes) { + for ((key, value) in changes) { + if (value == null) values.remove(key) else values[key] = value + changed += key + } + } + changed.distinct() + } + + private fun notify(changed: List) { + for (key in changed) for (listener in listeners) { + runCatching { listener.onSharedPreferenceChanged(this@DesktopSharedPreferences, key) } + } + } + } + + private fun write() = synchronized(file) { + val snapshot = synchronized(values) { values.toMap() } + val json = JsonObject(snapshot.mapValues { (_, value) -> encode(value) }) + val tmp = file.resolveSibling(file.fileName.toString() + ".tmp") + tmp.writeText(json.toString()) + Files.move(tmp, file, REPLACE_EXISTING, ATOMIC_MOVE) + } + + private fun read(): MutableMap { + val map = mutableMapOf() + if (!file.exists()) return map + runCatching { + for ((key, element) in Json.parseToJsonElement(file.readText()).jsonObject) { + decode(element.jsonObject)?.let { map[key] = it } + } + }.onFailure { println("ERROR SharedPreferences: Could not read $file: $it") } + return map + } + + private companion object { + /** All files are written from one thread, in order, like Android's apply() */ + val writer = Executors.newSingleThreadExecutor { Thread(it, "SharedPreferences writer").apply { isDaemon = true } } + + fun encode(value: Any): JsonObject = when (value) { + is String -> typed("string", JsonPrimitive(value)) + is Int -> typed("int", JsonPrimitive(value)) + is Long -> typed("long", JsonPrimitive(value)) + is Float -> typed("float", JsonPrimitive(value)) + is Boolean -> typed("boolean", JsonPrimitive(value)) + is Set<*> -> typed("set", JsonArray(value.map { JsonPrimitive(it as String) })) + else -> typed("string", JsonPrimitive(value.toString())) + } + + fun typed(type: String, value: kotlinx.serialization.json.JsonElement) = + JsonObject(mapOf("type" to JsonPrimitive(type), "value" to value)) + + fun decode(obj: JsonObject): Any? { + val value = obj["value"] ?: return null + return when (obj["type"]?.jsonPrimitive?.content) { + "string" -> value.jsonPrimitive.content + "int" -> value.jsonPrimitive.content.toInt() + "long" -> value.jsonPrimitive.content.toLong() + "float" -> value.jsonPrimitive.content.toFloat() + "boolean" -> value.jsonPrimitive.content.toBoolean() + "set" -> value.jsonArray.map { it.jsonPrimitive.content }.toSet() + else -> null + } + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt new file mode 100644 index 00000000000..fe7da2fc325 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt @@ -0,0 +1,171 @@ +package com.lagradost.cloudstream4.android + +import com.googlecode.d2j.Method +import com.googlecode.d2j.dex.Dex2jar +import com.googlecode.d2j.dex.DexExceptionHandler +import com.googlecode.d2j.node.DexCodeNode +import com.googlecode.d2j.node.DexFileNode +import com.googlecode.d2j.node.DexMethodNode +import com.googlecode.d2j.reader.BaseDexFileReader +import com.googlecode.d2j.reader.DexFileReader +import com.googlecode.d2j.reader.MultiDexFileReader +import com.googlecode.d2j.reader.Op +import com.googlecode.d2j.visitors.DexFileVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import java.lang.reflect.Modifier +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipFile +import kotlin.io.path.deleteIfExists + +/** The result of converting an Android extension */ +data class ConversionResult( + /** Methods that could not be converted. They throw when called, the rest of the extension still works */ + val failedMethods: Int, +) + +/** + * Converts an Android extension (.cs3, a zip with classes.dex and manifest.json) into a jar the + * desktop app can load, with dex2jar. The manifest is copied over, so the plugin class is found + * the same way as in a desktop jar. + */ +object DexConverter { + private const val MANIFEST = "manifest.json" + + /** What dex2jar reads with the options below: no debug info, names kept as they are */ + private const val READ_FLAGS = DexFileReader.SKIP_DEBUG or DexFileReader.DONT_SANITIZE_NAMES or DexFileReader.IGNORE_READ_EXCEPTION + + /** + * Methods this big are not converted. dex2jar's memory use grows steeply with a method's size and + * try blocks: StreamPlay's 26 000 instruction invokeMovieBox ran it out of 3 GB, while its next + * biggest method, of 6 000, needs 250 MB. A method this big would also pass the JVM's 64 KB limit. + */ + private const val MAX_INSTRUCTIONS = 12_000 + private const val MAX_COST = 3_000_000L + + /** + * How many times a conversion that ran out of memory is tried again, each time without the + * heaviest method left. How much memory a method needs cannot be told from its size: AniSnatch's + * loadLinks needs over 768 MB, while StreamCenter's loadTmdbMedia, as big and with as many try + * blocks, needs under 384 MB. + */ + private const val OUT_OF_MEMORY_RETRIES = 3 + + fun convert(cs3: Path, jar: Path): ConversionResult { + val dex = DexFileNode() + MultiDexFileReader.open(Files.readAllBytes(cs3)).accept(dex, READ_FLAGS) + var replaced = replaceHugeMethods(dex, cs3) + var retries = 0 + while (true) { + try { + return ConversionResult(replaced + translate(dex, cs3, jar)) + } catch (e: OutOfMemoryError) { + // What dex2jar built is unreachable now, so the memory is back + if (retries++ >= OUT_OF_MEMORY_RETRIES) throw e + val heaviest = methods(dex).maxByOrNull { cost(it.codeNode) } ?: throw e + println("WARNING DexConverter: Ran out of memory converting $cs3, trying again without ${heaviest.method}") + heaviest.codeNode = throwingCode(heaviest) + replaced++ + } + } + } + + /** Writes the jar, returns how many methods could not be converted */ + private fun translate(dex: DexFileNode, cs3: Path, jar: Path): Int { + val failed = AtomicInteger() + val handler = object : DexExceptionHandler { + override fun handleFileException(e: Exception) { + throw IllegalArgumentException("Not a readable Android extension: ${e.message ?: e}", e) + } + + /** Like the dex2jar command, the method gets a body that throws instead */ + override fun handleMethodTranslateException(method: Method, node: DexMethodNode, mv: MethodVisitor, e: Exception) { + failed.incrementAndGet() + println("WARNING DexConverter: Could not convert $method in $cs3: $e") + mv.visitTypeInsn(Opcodes.NEW, "java/lang/RuntimeException") + mv.visitInsn(Opcodes.DUP) + mv.visitLdcInsn("This method of the extension could not be converted for desktop: $e") + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/RuntimeException", "", "(Ljava/lang/String;)V", false) + mv.visitInsn(Opcodes.ATHROW) + } + } + + val tmp = jar.resolveSibling("${jar.fileName}.tmp") + tmp.deleteIfExists() + try { + // The same options as the dex2jar command's defaults, except that names are kept: + // Kotlin names such as "constructor-impl" are valid on the JVM, and renaming them breaks the calls + Dex2jar.from(NodeReader(dex)) + .withExceptionHandler(handler) + .reUseReg(false) + .topoLogicalSort() + .skipDebug(true) + .dontSanitizeNames(true) + .optimizeSynchronized(false) + .printIR(false) + .noCode(false) + .skipExceptions(false) + .to(tmp) + copyManifest(cs3, tmp) + Files.move(tmp, jar, REPLACE_EXISTING, ATOMIC_MOVE) + } finally { + tmp.deleteIfExists() + } + return failed.get() + } + + private fun methods(dex: DexFileNode) = dex.clzs.orEmpty().asSequence().flatMap { it.methods.orEmpty() }.filter { it.codeNode != null } + + /** Roughly how much work dex2jar has with a method */ + private fun cost(code: DexCodeNode): Long = code.stmts.size.toLong() * (code.totalRegister + (code.tryStmts?.size ?: 0)) + + /** Gives methods too big to convert a body that throws. Returns how many there were */ + private fun replaceHugeMethods(dex: DexFileNode, cs3: Path): Int { + var replaced = 0 + for (method in methods(dex)) { + val instructions = method.codeNode.stmts.size + if (instructions <= MAX_INSTRUCTIONS && cost(method.codeNode) <= MAX_COST) continue + println("WARNING DexConverter: ${method.method} in $cs3 is too big to convert ($instructions instructions)") + method.codeNode = throwingCode(method) + replaced++ + } + return replaced + } + + /** throw new RuntimeException(message), with the method's parameters in the last registers like dex expects */ + private fun throwingCode(method: DexMethodNode): DexCodeNode { + val parameterRegisters = method.method.parameterTypes.sumOf { if (it == "J" || it == "D") 2 else 1 } + + if (method.access and Modifier.STATIC != 0) 0 else 1 + return DexCodeNode().apply { + visitRegister(2 + parameterRegisters) + visitTypeStmt(Op.NEW_INSTANCE, 0, 0, "Ljava/lang/RuntimeException;") + visitConstStmt(Op.CONST_STRING, 1, "This method of the extension is too big to convert for desktop") + visitMethodStmt(Op.INVOKE_DIRECT, intArrayOf(0, 1), Method("Ljava/lang/RuntimeException;", "", arrayOf("Ljava/lang/String;"), "V")) + visitStmt1R(Op.THROW, 0) + } + } + + /** Hands dex2jar the dex that was read and edited above */ + private class NodeReader(private val dex: DexFileNode) : BaseDexFileReader { + override fun getDexVersion(): Int = dex.dexVersion + override fun getClassNames(): List = dex.clzs.map { it.className } + override fun accept(dv: DexFileVisitor) = dex.accept(dv) + override fun accept(dv: DexFileVisitor, config: Int) = dex.accept(dv) + override fun accept(dv: DexFileVisitor, classIdx: Int, config: Int) = dex.clzs[classIdx].accept(dv) + } + + private fun copyManifest(cs3: Path, jar: Path) { + val manifest = ZipFile(cs3.toFile()).use { zip -> + zip.getEntry(MANIFEST)?.let { entry -> zip.getInputStream(entry).use { it.readBytes() } } + } ?: return + // Opened by path: a "jar:" uri breaks on a folder name with "!" in it + FileSystems.newFileSystem(jar).use { fs -> + Files.write(fs.getPath(MANIFEST), manifest) + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/MissingMembers.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/MissingMembers.kt new file mode 100644 index 00000000000..459c3f239b6 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/MissingMembers.kt @@ -0,0 +1,131 @@ +package com.lagradost.cloudstream4.android + +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type + +/** + * The desktop versions of Android classes, such as Context, only have the members extensions + * commonly use. When an extension class uses one they lack, such as Context.getContentResolver(), + * the call is redirected while the class loads to a generated helper that returns a default value, + * the same thing a stub does. Without this the call would fail with NoSuchMethodError. + * + * Only the desktop versions in this module are treated this way, never the library or the JDK, + * where a missing member is a real error. + */ +internal class MissingMembers( + private val model: StubModel, + private val generator: StubGenerator, + private val parent: ClassLoader, +) { + private val standInLocation = MissingMembers::class.java.protectionDomain?.codeSource?.location + + private class Helper(val name: String, val desc: String) + + /** Helper classes waiting to be defined, by binary class name */ + private val helpers = HashMap>() + + /** The desktop version a member is missing from, or null when it exists or is not ours to fill in */ + private val missing = HashMap?>() + + private fun parentClass(internalName: String): Class<*>? = try { + Class.forName(internalName.replace('/', '.'), false, parent) + } catch (_: ClassNotFoundException) { + null + } catch (_: LinkageError) { + null + } + + private fun isStandIn(cls: Class<*>) = StubHierarchy.isStubbed(Type.getInternalName(cls)) && + standInLocation != null && cls.protectionDomain?.codeSource?.location == standInLocation + + private fun missingFrom(owner: String, name: String, desc: String, isField: Boolean): Class<*>? = + synchronized(missing) { + missing.getOrPut("$owner.$name$desc$isField") { + if (owner.startsWith("[")) return@getOrPut null + // Members used through an extension class come from its first ancestor outside the extension + val ancestor = model.firstAncestorOutside(owner, name + desc) ?: return@getOrPut null + if (name == "" && ancestor != owner) return@getOrPut null + val cls = parentClass(ancestor)?.takeIf(::isStandIn) ?: return@getOrPut null + val exists = when { + isField -> generator.hasField(cls, name, desc) + name == "" -> cls.declaredConstructors.any { Type.getConstructorDescriptor(it) == desc } + else -> generator.hasMethod(cls, name, desc) + } + if (exists) null else cls + } + } + + /** Returns the class with calls to missing members redirected, or the same bytes when there are none */ + fun rewrite(binaryName: String, bytes: ByteArray): ByteArray { + val reader = ClassReader(bytes) + val writer = ClassWriter(reader, 0) + val helperName = binaryName.replace('.', '/') + "\$\$DesktopMissing" + val added = mutableListOf() + + fun helper(desc: String): Helper = Helper("missing${added.size}", desc).also { added += it } + + reader.accept(object : ClassVisitor(Opcodes.ASM9, writer) { + override fun visitMethod(access: Int, name: String, descriptor: String, signature: String?, exceptions: Array?): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)) { + override fun visitMethodInsn(opcode: Int, owner: String, name: String, desc: String, isInterface: Boolean) { + val cls = missingFrom(owner, name, desc, isField = false) + when { + cls == null -> super.visitMethodInsn(opcode, owner, name, desc, isInterface) + name == "" -> { + // The arguments are used up by the helper and the object made with the constructor that exists + if (cls.declaredConstructors.none { it.parameterCount == 0 }) { + super.visitMethodInsn(opcode, owner, name, desc, isInterface) + return + } + val h = helper(desc) + super.visitMethodInsn(Opcodes.INVOKESTATIC, helperName, h.name, h.desc, false) + super.visitMethodInsn(Opcodes.INVOKESPECIAL, owner, "", "()V", false) + } + else -> { + val h = helper(if (opcode == Opcodes.INVOKESTATIC) desc else "(L$owner;" + desc.substring(1)) + super.visitMethodInsn(Opcodes.INVOKESTATIC, helperName, h.name, h.desc, false) + } + } + } + + override fun visitFieldInsn(opcode: Int, owner: String, name: String, desc: String) { + if (missingFrom(owner, name, desc, isField = true) == null) return super.visitFieldInsn(opcode, owner, name, desc) + val h = helper( + when (opcode) { + Opcodes.GETSTATIC -> "()$desc" + Opcodes.GETFIELD -> "(L$owner;)$desc" + Opcodes.PUTSTATIC -> "($desc)V" + else -> "(L$owner;$desc)V" + } + ) + super.visitMethodInsn(Opcodes.INVOKESTATIC, helperName, h.name, h.desc, false) + } + } + }, 0) + + if (added.isEmpty()) return bytes + synchronized(helpers) { helpers[helperName.replace('/', '.')] = added } + return writer.toByteArray() + } + + /** The helper class for an extension class rewritten above, or null when the name is not one */ + fun helperClass(binaryName: String): ByteArray? { + val list = synchronized(helpers) { helpers.remove(binaryName) } ?: return null + val writer = ClassWriter(ClassWriter.COMPUTE_MAXS) + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC or Opcodes.ACC_FINAL or Opcodes.ACC_SUPER, binaryName.replace('.', '/'), null, "java/lang/Object", null) + for (helper in list) { + writer.visitMethod(Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC, helper.name, helper.desc, null, null).apply { + visitCode() + generator.returnDefault(this, Type.getReturnType(helper.desc)) + visitMaxs(0, 0) + visitEnd() + } + } + writer.visitEnd() + return writer.toByteArray() + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubGenerator.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubGenerator.kt new file mode 100644 index 00000000000..cc33518f5dd --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubGenerator.kt @@ -0,0 +1,342 @@ +package com.lagradost.cloudstream4.android + +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import java.nio.file.Path +import java.util.jar.JarFile + +/** What an extension uses of each class desktop has to generate a stub for */ +internal class StubModel private constructor() { + class Member(val name: String, val desc: String, val isStatic: Boolean) + + class TypeUse { + /** Called with invokeinterface or implemented by an extension class */ + var usedAsInterface = false + + /** Created with new or extended by an extension class */ + var usedAsClass = false + val methods = LinkedHashMap() + val fields = LinkedHashMap() + + val isInterface get() = usedAsInterface && !usedAsClass + } + + private class ExtensionClass(val superName: String?, val declared: MutableSet = mutableSetOf()) + + /** A use of a member through an extension class, which may come from a stubbed superclass */ + private class InheritedUse(val owner: String, val member: Member, val isField: Boolean) + + private val extensionClasses = HashMap() + private val inheritedUses = mutableListOf() + val types = HashMap() + + private fun use(name: String) = types.getOrPut(name) { TypeUse() } + + private fun record(owner: String, member: Member, isField: Boolean) { + when { + owner.startsWith("[") -> {} + StubHierarchy.isStubbed(owner) -> { + val use = use(owner) + (if (isField) use.fields else use.methods).putIfAbsent(member.name + member.desc, member) + } + else -> inheritedUses += InheritedUse(owner, member, isField) + } + } + + private fun scanClass(bytes: ByteArray) { + ClassReader(bytes).accept(object : ClassVisitor(Opcodes.ASM9) { + lateinit var current: ExtensionClass + + override fun visit(version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array?) { + current = ExtensionClass(superName).also { extensionClasses[name] = it } + if (superName != null && StubHierarchy.isStubbed(superName)) use(superName).usedAsClass = true + interfaces?.filter(StubHierarchy::isStubbed)?.forEach { use(it).usedAsInterface = true } + } + + override fun visitField(access: Int, name: String, descriptor: String, signature: String?, value: Any?) = + null.also { current.declared += name + descriptor } + + override fun visitMethod(access: Int, name: String, descriptor: String, signature: String?, exceptions: Array?): MethodVisitor { + current.declared += name + descriptor + return object : MethodVisitor(Opcodes.ASM9) { + override fun visitMethodInsn(opcode: Int, owner: String, name: String, descriptor: String, isInterface: Boolean) { + if (isInterface && StubHierarchy.isStubbed(owner)) use(owner).usedAsInterface = true + record(owner, Member(name, descriptor, opcode == Opcodes.INVOKESTATIC), isField = false) + } + + override fun visitFieldInsn(opcode: Int, owner: String, name: String, descriptor: String) { + val isStatic = opcode == Opcodes.GETSTATIC || opcode == Opcodes.PUTSTATIC + record(owner, Member(name, descriptor, isStatic), isField = true) + } + + override fun visitTypeInsn(opcode: Int, type: String) { + if (opcode == Opcodes.NEW && StubHierarchy.isStubbed(type)) use(type).usedAsClass = true + } + } + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + + /** + * Where a member used through [owner] comes from: [owner] itself when it is not an extension + * class, else its first ancestor outside the extension. Null when the extension declares it. + */ + fun firstAncestorOutside(owner: String, key: String): String? { + var current: String? = owner + while (current != null) { + val cls = extensionClasses[current] ?: return current + if (key in cls.declared) return null + current = cls.superName + } + return null + } + + /** Members used through an extension class but declared nowhere in the extension belong to its stubbed ancestor */ + private fun resolveInheritedUses() { + for (use in inheritedUses) { + val key = use.member.name + use.member.desc + var current: String? = use.owner + while (current != null) { + val cls = extensionClasses[current] ?: break + if (key in cls.declared) break + current = cls.superName + } + if (current != null && extensionClasses[current] == null && StubHierarchy.isStubbed(current)) { + val type = use(current) + (if (use.isField) type.fields else type.methods).putIfAbsent(key, use.member) + } + } + inheritedUses.clear() + } + + companion object { + fun scan(jar: Path): StubModel = StubModel().apply { + JarFile(jar.toFile()).use { file -> + for (entry in file.entries()) { + if (!entry.name.endsWith(".class")) continue + runCatching { scanClass(file.getInputStream(entry).use { it.readBytes() }) } + .onFailure { println("WARNING StubModel: Could not read ${entry.name} in $jar: $it") } + } + } + resolveInheritedUses() + } + } +} + +/** + * Writes the stub classes. A stub has the members the extension uses: constructors that do nothing, + * and methods that return a default value. Where the value is an object the stub can create, such + * as Toast.makeText's Toast, it returns a new one so chained calls keep doing nothing rather than + * failing on null. Members a real desktop class above the stub already has are left out, so the + * stub never hides them. + */ +internal class StubGenerator(private val model: StubModel, private val parent: ClassLoader) { + private val parentClasses = HashMap?>() + + private fun parentClass(internalName: String): Class<*>? = parentClasses.getOrPut(internalName) { + try { + Class.forName(internalName.replace('/', '.'), false, parent) + } catch (_: ClassNotFoundException) { + null + } catch (_: LinkageError) { + null + } + } + + private fun isInterface(internalName: String): Boolean = + parentClass(internalName)?.isInterface ?: (model.types[internalName]?.isInterface == true) + + /** The first real class above a stub, where the stub's own ancestors end */ + private fun firstRealAncestor(superName: String): Class<*>? { + var current = superName + repeat(32) { + parentClass(current)?.let { return it } + if (!StubHierarchy.isStubbed(current)) return null + current = StubHierarchy.superclassOf(current) + } + return null + } + + fun hasMethod(cls: Class<*>, name: String, desc: String): Boolean { + val seen = HashSet>() + val queue = ArrayDeque(listOf(cls)) + while (queue.isNotEmpty()) { + val c = queue.removeFirst() + if (!seen.add(c)) continue + if (c.declaredMethods.any { it.name == name && Type.getMethodDescriptor(it) == desc }) return true + c.superclass?.let(queue::add) + queue.addAll(c.interfaces) + } + return false + } + + fun hasField(cls: Class<*>, name: String, desc: String): Boolean { + var c: Class<*>? = cls + while (c != null) { + if (c.declaredFields.any { it.name == name && Type.getDescriptor(it.type) == desc }) return true + if (c.interfaces.any { i -> i.fields.any { it.name == name } }) return true + c = c.superclass + } + return false + } + + fun generate(internalName: String): ByteArray { + val use = model.types[internalName] ?: StubModel.TypeUse() + val isInterface = use.isInterface + val superName = if (isInterface) "java/lang/Object" else StubHierarchy.superclassOf(internalName) + val realAncestor = firstRealAncestor(superName) ?: Any::class.java + + val writer = ClassWriter(ClassWriter.COMPUTE_MAXS) + val access = Opcodes.ACC_PUBLIC or if (isInterface) Opcodes.ACC_INTERFACE or Opcodes.ACC_ABSTRACT else Opcodes.ACC_SUPER + writer.visit(Opcodes.V1_8, access, internalName, null, superName, null) + + // Static object fields get an instance when the stub can make one, such as a Kotlin Companion or INSTANCE + val initialized = mutableListOf() + for (field in use.fields.values) { + if (hasField(realAncestor, field.name, field.desc)) continue + val isStatic = isInterface || field.isStatic + val fieldAccess = Opcodes.ACC_PUBLIC or when { + isInterface -> Opcodes.ACC_STATIC or Opcodes.ACC_FINAL + field.isStatic -> Opcodes.ACC_STATIC + else -> 0 + } + writer.visitField(fieldAccess, field.name, field.desc, null, null).visitEnd() + val type = Type.getType(field.desc) + if (isStatic && type.sort == Type.OBJECT && canCreate(type.internalName)) initialized += field + } + if (initialized.isNotEmpty()) { + writer.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null).apply { + visitCode() + for (field in initialized) { + newInstance(this, Type.getType(field.desc).internalName) + visitFieldInsn(Opcodes.PUTSTATIC, internalName, field.name, field.desc) + } + visitInsn(Opcodes.RETURN) + visitMaxs(0, 0) + visitEnd() + } + } + + if (!isInterface) { + val constructors = use.methods.values.filter { it.name == "" }.map { it.desc }.toMutableSet() + constructors += "()V" + for (desc in constructors) { + writer.visitMethod(Opcodes.ACC_PUBLIC, "", desc, null, null).apply { + visitCode() + visitVarInsn(Opcodes.ALOAD, 0) + visitMethodInsn(Opcodes.INVOKESPECIAL, superName, "", "()V", false) + visitInsn(Opcodes.RETURN) + visitMaxs(0, 0) + visitEnd() + } + } + } + + val written = HashSet() + for (method in use.methods.values) { + if (method.name.startsWith("<")) continue + // A static and an instance method cannot share a name and descriptor, keep the first use + if (!written.add(method.name + method.desc)) continue + if (!method.isStatic && hasMethod(if (isInterface) Any::class.java else realAncestor, method.name, method.desc)) continue + if (method.isStatic && !isInterface && hasMethod(realAncestor, method.name, method.desc)) continue + val methodAccess = Opcodes.ACC_PUBLIC or if (method.isStatic) Opcodes.ACC_STATIC else 0 + writer.visitMethod(methodAccess, method.name, method.desc, null, null).apply { + visitCode() + returnDefault(this, Type.getReturnType(method.desc)) + visitMaxs(0, 0) + visitEnd() + } + } + + writer.visitEnd() + return writer.toByteArray() + } + + /** True when a stub can return a new instance of the type */ + private fun canCreate(internalName: String): Boolean { + val real = parentClass(internalName) + if (real != null) { + val modifiers = real.modifiers + return !real.isInterface && !java.lang.reflect.Modifier.isAbstract(modifiers) && + java.lang.reflect.Modifier.isPublic(modifiers) && + real.constructors.any { it.parameterCount == 0 } && + StubHierarchy.isStubbed(internalName) + } + return StubHierarchy.isStubbed(internalName) && !isInterface(internalName) + } + + fun returnDefault(mv: MethodVisitor, type: Type) { + when (type.sort) { + Type.VOID -> mv.visitInsn(Opcodes.RETURN) + Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> { + mv.visitInsn(Opcodes.ICONST_0) + mv.visitInsn(Opcodes.IRETURN) + } + Type.LONG -> { + mv.visitInsn(Opcodes.LCONST_0) + mv.visitInsn(Opcodes.LRETURN) + } + Type.FLOAT -> { + mv.visitInsn(Opcodes.FCONST_0) + mv.visitInsn(Opcodes.FRETURN) + } + Type.DOUBLE -> { + mv.visitInsn(Opcodes.DCONST_0) + mv.visitInsn(Opcodes.DRETURN) + } + Type.ARRAY -> { + mv.visitInsn(Opcodes.ICONST_0) + val element = Type.getType(type.descriptor.substring(1)) + when (element.sort) { + Type.OBJECT, Type.ARRAY -> mv.visitTypeInsn(Opcodes.ANEWARRAY, element.internalName) + else -> mv.visitIntInsn(Opcodes.NEWARRAY, newArrayCode(element)) + } + mv.visitInsn(Opcodes.ARETURN) + } + else -> { + val name = type.internalName + val created = when (name) { + "java/lang/String", "java/lang/CharSequence" -> { + mv.visitLdcInsn("") + true + } + "java/util/List", "java/util/Collection", "java/lang/Iterable", "java/util/ArrayList" -> newInstance(mv, "java/util/ArrayList") + "java/util/Set", "java/util/HashSet" -> newInstance(mv, "java/util/HashSet") + "java/util/Map", "java/util/HashMap" -> newInstance(mv, "java/util/HashMap") + // Such as View.getContext(), the desktop activity is every context there is + "android/content/Context", "android/content/ContextWrapper", "android/view/ContextThemeWrapper", "android/app/Activity" -> { + val desktop = Type.getInternalName(DesktopAndroid::class.java) + mv.visitFieldInsn(Opcodes.GETSTATIC, desktop, "INSTANCE", "L$desktop;") + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, desktop, "getActivity", "()Landroid/app/Activity;", false) + true + } + else -> if (canCreate(name)) newInstance(mv, name) else false + } + if (!created) mv.visitInsn(Opcodes.ACONST_NULL) + mv.visitInsn(Opcodes.ARETURN) + } + } + } + + private fun newInstance(mv: MethodVisitor, internalName: String): Boolean { + mv.visitTypeInsn(Opcodes.NEW, internalName) + mv.visitInsn(Opcodes.DUP) + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalName, "", "()V", false) + return true + } + + private fun newArrayCode(element: Type) = when (element.sort) { + Type.BOOLEAN -> Opcodes.T_BOOLEAN + Type.CHAR -> Opcodes.T_CHAR + Type.BYTE -> Opcodes.T_BYTE + Type.SHORT -> Opcodes.T_SHORT + Type.INT -> Opcodes.T_INT + Type.LONG -> Opcodes.T_LONG + Type.FLOAT -> Opcodes.T_FLOAT + else -> Opcodes.T_DOUBLE + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt new file mode 100644 index 00000000000..7ec049cf6e4 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt @@ -0,0 +1,143 @@ +package com.lagradost.cloudstream4.android + +/** + * Superclasses of the Android classes desktop generates stubs for. The JVM checks these when an + * extension passes, say, a TextView where a View is expected, so the stubs need the real + * hierarchy. Classes not listed extend Object, or Exception and Error going by their name. + */ +internal object StubHierarchy { + /** Namespaces whose missing classes are generated, in internal name form */ + val stubbedPrefixes = listOf( + "android/", + "androidx/", + "dalvik/", + "com/android/", + "com/google/android/", + "org/xmlpull/", + // App classes extensions use that desktop has no version of, such as the sync providers + "com/lagradost/cloudstream3/", + ) + + fun isStubbed(internalName: String) = stubbedPrefixes.any(internalName::startsWith) + + fun superclassOf(internalName: String): String { + superclasses[internalName]?.let { return it } + val simple = internalName.substringAfterLast('/').substringAfterLast('$') + return when { + simple.endsWith("Exception") -> "java/lang/RuntimeException" + simple.endsWith("Error") -> "java/lang/Error" + else -> "java/lang/Object" + } + } + + private val superclasses: Map = buildMap { + fun extends(parent: String, vararg children: String) = children.forEach { put(it, parent) } + + // Views + extends("android/view/View", "android/view/ViewGroup", "android/widget/TextView", "android/widget/ImageView", + "android/widget/ProgressBar", "android/view/SurfaceView", "android/view/TextureView", "android/widget/Space") + extends("android/view/ViewGroup", "android/widget/LinearLayout", "android/widget/FrameLayout", + "android/widget/RelativeLayout", "android/widget/AdapterView", "android/widget/AbsoluteLayout", + "android/widget/GridLayout", "androidx/recyclerview/widget/RecyclerView", + "androidx/constraintlayout/widget/ConstraintLayout", "androidx/coordinatorlayout/widget/CoordinatorLayout", + "androidx/viewpager/widget/ViewPager", "com/google/android/material/internal/FlowLayout") + extends("android/widget/AbsoluteLayout", "android/webkit/WebView") + extends("android/widget/FrameLayout", "android/widget/ScrollView", "android/widget/HorizontalScrollView", + "androidx/cardview/widget/CardView", "androidx/core/widget/NestedScrollView", + "androidx/fragment/app/FragmentContainerView", "androidx/swiperefreshlayout/widget/SwipeRefreshLayout") + extends("android/widget/LinearLayout", "android/widget/RadioGroup", "android/widget/TableLayout", + "android/widget/TableRow", "android/widget/SearchView", "androidx/appcompat/widget/LinearLayoutCompat", + "com/google/android/material/textfield/TextInputLayout") + extends("android/widget/AdapterView", "android/widget/AbsListView", "android/widget/AbsSpinner") + extends("android/widget/AbsListView", "android/widget/ListView", "android/widget/GridView") + extends("android/widget/AbsSpinner", "android/widget/Spinner") + extends("android/widget/TextView", "android/widget/Button", "android/widget/EditText", + "androidx/appcompat/widget/AppCompatTextView") + extends("android/widget/Button", "android/widget/CompoundButton", "androidx/appcompat/widget/AppCompatButton") + extends("android/widget/CompoundButton", "android/widget/CheckBox", "android/widget/Switch", + "android/widget/RadioButton", "android/widget/ToggleButton", "androidx/appcompat/widget/SwitchCompat") + extends("android/widget/EditText", "android/widget/AutoCompleteTextView", "androidx/appcompat/widget/AppCompatEditText") + extends("android/widget/ImageView", "android/widget/ImageButton", "androidx/appcompat/widget/AppCompatImageView") + extends("android/widget/ProgressBar", "android/widget/AbsSeekBar") + extends("android/widget/AbsSeekBar", "android/widget/SeekBar", "android/widget/RatingBar") + extends("androidx/appcompat/widget/AppCompatButton", "com/google/android/material/button/MaterialButton") + extends("androidx/appcompat/widget/AppCompatEditText", "com/google/android/material/textfield/TextInputEditText") + extends("android/widget/CheckBox", "androidx/appcompat/widget/AppCompatCheckBox") + extends("androidx/appcompat/widget/AppCompatCheckBox", "com/google/android/material/chip/Chip") + extends("com/google/android/material/internal/FlowLayout", "com/google/android/material/chip/ChipGroup") + extends("androidx/appcompat/widget/SwitchCompat", "com/google/android/material/materialswitch/MaterialSwitch", + "com/google/android/material/switchmaterial/SwitchMaterial") + extends("androidx/cardview/widget/CardView", "com/google/android/material/card/MaterialCardView") + + // Layout parameters + extends("android/view/ViewGroup\$LayoutParams", "android/view/ViewGroup\$MarginLayoutParams", + "android/view/WindowManager\$LayoutParams") + extends("android/view/ViewGroup\$MarginLayoutParams", "android/widget/LinearLayout\$LayoutParams", + "android/widget/FrameLayout\$LayoutParams", "android/widget/RelativeLayout\$LayoutParams", + "androidx/recyclerview/widget/RecyclerView\$LayoutParams", + "androidx/constraintlayout/widget/ConstraintLayout\$LayoutParams") + + // Drawables and animation + extends("android/graphics/drawable/Drawable", "android/graphics/drawable/GradientDrawable", + "android/graphics/drawable/ColorDrawable", "android/graphics/drawable/BitmapDrawable", + "android/graphics/drawable/ShapeDrawable", "android/graphics/drawable/LayerDrawable", + "android/graphics/drawable/DrawableContainer", "android/graphics/drawable/InsetDrawable") + extends("android/graphics/drawable/DrawableContainer", "android/graphics/drawable/StateListDrawable") + extends("android/graphics/drawable/LayerDrawable", "android/graphics/drawable/RippleDrawable") + extends("android/animation/Animator", "android/animation/ValueAnimator", "android/animation/AnimatorSet") + extends("android/animation/ValueAnimator", "android/animation/ObjectAnimator") + extends("android/text/style/CharacterStyle", "android/text/style/MetricAffectingSpan", + "android/text/style/ForegroundColorSpan", "android/text/style/BackgroundColorSpan", + "android/text/style/ClickableSpan", "android/text/style/UnderlineSpan", "android/text/style/StrikethroughSpan") + extends("android/text/style/MetricAffectingSpan", "android/text/style/StyleSpan", + "android/text/style/RelativeSizeSpan", "android/text/style/AbsoluteSizeSpan", "android/text/style/TypefaceSpan") + + // Dialogs, fragments and activities + extends("android/app/Dialog", "android/app/AlertDialog", "androidx/appcompat/app/AppCompatDialog") + extends("androidx/appcompat/app/AppCompatDialog", "androidx/appcompat/app/AlertDialog", + "com/google/android/material/bottomsheet/BottomSheetDialog") + extends("android/app/Fragment", "android/app/DialogFragment") + extends("androidx/fragment/app/Fragment", "androidx/fragment/app/DialogFragment", + "androidx/preference/PreferenceFragmentCompat") + extends("androidx/fragment/app/DialogFragment", "androidx/appcompat/app/AppCompatDialogFragment") + extends("androidx/appcompat/app/AppCompatDialogFragment", "com/google/android/material/bottomsheet/BottomSheetDialogFragment") + extends("android/app/Activity", "androidx/core/app/ComponentActivity") + extends("androidx/core/app/ComponentActivity", "androidx/activity/ComponentActivity") + extends("androidx/activity/ComponentActivity", "androidx/fragment/app/FragmentActivity") + extends("androidx/fragment/app/FragmentActivity", "androidx/appcompat/app/AppCompatActivity") + extends("android/content/ContextWrapper", "android/app/Service", "android/view/ContextThemeWrapper") + extends("android/view/ContextThemeWrapper", "androidx/appcompat/view/ContextThemeWrapper") + + // Preferences + extends("androidx/preference/Preference", "androidx/preference/TwoStatePreference", + "androidx/preference/DialogPreference", "androidx/preference/PreferenceGroup", + "androidx/preference/SeekBarPreference") + extends("androidx/preference/TwoStatePreference", "androidx/preference/SwitchPreferenceCompat", + "androidx/preference/CheckBoxPreference", "androidx/preference/SwitchPreference") + extends("androidx/preference/DialogPreference", "androidx/preference/EditTextPreference", + "androidx/preference/ListPreference", "androidx/preference/MultiSelectListPreference") + extends("androidx/preference/PreferenceGroup", "androidx/preference/PreferenceCategory", + "androidx/preference/PreferenceScreen") + + // Lists + extends("androidx/recyclerview/widget/RecyclerView\$LayoutManager", "androidx/recyclerview/widget/LinearLayoutManager") + extends("androidx/recyclerview/widget/LinearLayoutManager", "androidx/recyclerview/widget/GridLayoutManager") + extends("androidx/recyclerview/widget/RecyclerView\$Adapter", "androidx/recyclerview/widget/ListAdapter") + extends("androidx/recyclerview/widget/RecyclerView\$ItemDecoration", "androidx/recyclerview/widget/ItemTouchHelper") + extends("androidx/recyclerview/widget/ItemTouchHelper\$Callback", "androidx/recyclerview/widget/ItemTouchHelper\$SimpleCallback") + extends("android/widget/BaseAdapter", "android/widget/ArrayAdapter") + + // CloudStream app account and sync providers, which some extensions read + val sync = "com/lagradost/cloudstream3/syncproviders" + extends("$sync/AuthAPI", "$sync/SyncAPI", "$sync/SubtitleAPI", "$sync/BackupAPI") + extends("$sync/SyncAPI", "$sync/providers/AniListApi", "$sync/providers/MALApi", "$sync/providers/KitsuApi", + "$sync/providers/SimklApi") + extends("$sync/SubtitleAPI", "$sync/providers/OpenSubtitlesApi", "$sync/providers/SubDlApi", + "$sync/providers/SubSourceApi") + extends("$sync/AuthRepo", "$sync/SyncRepo", "$sync/SubtitleRepo") + + // Lifecycle + extends("androidx/lifecycle/LiveData", "androidx/lifecycle/MutableLiveData") + extends("androidx/lifecycle/MutableLiveData", "androidx/lifecycle/MediatorLiveData") + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 539c51d36f5..f0f82ab4f74 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,4 +1,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.net.URI +import java.security.MessageDigest +import javax.inject.Inject plugins { alias(libs.plugins.kotlin.multiplatform) @@ -14,6 +17,10 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.collections.immutable) + implementation(libs.coil.network.okhttp) + implementation(libs.jna) + implementation(libs.haze) + implementation(libs.okhttp) implementation(compose.desktop.currentOs) { // compose.desktop.currentOs imports the wrong material 2, so we exclude it exclude(group = "org.jetbrains.compose.material", module = "material") @@ -29,23 +36,114 @@ tasks.withType { jvmArgs("--enable-native-access=ALL-UNNAMED") } +/** + * Downloads the pinned libmpv build that plays video and unpacks libmpv-2.dll from it. + * Both downloads are checked against their SHA-256 and kept in the build folder between builds. + */ +abstract class PrepareLibmpv : DefaultTask() { + @get:Input abstract val archiveUrl: Property + @get:Input abstract val archiveSha256: Property + @get:Input abstract val sevenZipUrl: Property + @get:Input abstract val sevenZipSha256: Property + @get:Internal abstract val downloadDir: DirectoryProperty + @get:OutputDirectory abstract val outputDir: DirectoryProperty + @get:Inject abstract val exec: ExecOperations + + @TaskAction + fun prepare() { + val downloads = downloadDir.get().asFile.apply { mkdirs() } + val archive = fetch(archiveUrl.get(), archiveSha256.get(), downloads.resolve("mpv-dev.7z")) + val sevenZip = fetch(sevenZipUrl.get(), sevenZipSha256.get(), downloads.resolve("7zr.exe")) + val out = outputDir.get().asFile.apply { deleteRecursively(); mkdirs() } + exec.exec { commandLine(sevenZip.absolutePath, "x", "-y", "-o${out.absolutePath}", archive.absolutePath, "libmpv-2.dll") } + } + + private fun fetch(url: String, sha256: String, file: File): File { + if (!file.exists() || file.sha256() != sha256) { + logger.lifecycle("Downloading $url") + URI(url).toURL().openStream().use { input -> file.outputStream().use { input.copyTo(it) } } + } + val actual = file.sha256() + if (actual != sha256) { + file.delete() + throw GradleException("$url has SHA-256 $actual, expected $sha256") + } + return file + } + + private fun File.sha256(): String = + MessageDigest.getInstance("SHA-256").digest(readBytes()).joinToString("") { "%02x".format(it) } +} + +val prepareLibmpv by tasks.registering(PrepareLibmpv::class) { + // Built by the libmpv workflow, which publishes the complete source next to it. The release workflow + // copies that source into each app release from the release named here, so keep this URL first + archiveUrl = "https://github.com/harsh-pockt/cloudstream/releases/download/libmpv-20261004-git-413ff0b1cd/mpv-dev-x86_64-20261004-git-413ff0b1cd.7z" + archiveSha256 = "356bec543ce3acd8811f10b11cddaaca9b6946226b9c8dc99e9542c61d37b672" + // The archive uses the BCJ2 filter, which only 7-Zip itself unpacks. 7zr.exe is its small standalone version, + // mirrored too: 7-zip.org replaces it with each new version + sevenZipUrl = "https://github.com/harsh-pockt/cloudstream/releases/download/libmpv-20261002/7zr.exe" + sevenZipSha256 = "ad4c82fadcbdf93c03b4fc440f300509c7d60c5c2f4d183e35d9d70d6957037d" + downloadDir = layout.buildDirectory.dir("libmpv-download") + // In the windows folder of the app resources, so it is only bundled on Windows + outputDir = layout.buildDirectory.dir("libmpv/windows") + onlyIf { System.getProperty("os.name").startsWith("Windows") } +} + +/** Files installed next to the app: the notices and licence from src/app-resources, and libmpv on Windows */ +val assembleAppResources by tasks.registering(Sync::class) { + from("src/app-resources") + from(rootProject.file("LICENSE")) { into("common"); rename { "LICENSE.txt" } } + from(prepareLibmpv.map { it.outputDir.get().asFile.parentFile }) + into(layout.buildDirectory.dir("app-resources")) +} + +tasks.matching { it.name == "prepareAppResources" }.configureEach { dependsOn(assembleAppResources) } + compose.desktop { application { mainClass = "com.lagradost.cloudstream4.MainKt" + // Read by AppVersion, for the update check. The release workflow passes the tag's version, which + // can be a pre-release such as 4.8.0-beta.2 + jvmArgs("-Dcloudstream.version=${providers.gradleProperty("appVersion").getOrElse(libs.versions.versionName.get())}") + // Memory. Without a limit the heap may grow to a quarter of the computer's RAM before it is + // collected, while the app uses well under 100 MB of it. When idle, the heap shrinks and the + // memory goes back to Windows. Strings repeated across extensions' results are kept once + jvmArgs( + "-Xmx1g", + "-XX:+UseG1GC", + "-XX:G1PeriodicGCInterval=60000", + "-XX:MinHeapFreeRatio=10", + "-XX:MaxHeapFreeRatio=30", + "-XX:+UseStringDeduplication", + ) nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "CloudStream" - packageVersion = "1.0.0" + // Holds libmpv-2.dll and the notices, found at runtime through the compose.application.resources.dir property + appResourcesRootDir = layout.buildDirectory.dir("app-resources") + // MSI requires MAJOR.MINOR.BUILD with numbers only, and only replaces an installed copy with a + // higher one: two betas of 4.8.0 would both be 4.8.0. So the release workflow passes + // MAJOR.MINOR., which grows with every release + packageVersion = providers.gradleProperty("msiVersion").getOrElse(libs.versions.versionName.get()) + + // The packaged runtime only contains these JDK modules, regenerate with ./gradlew :desktopApp:suggestRuntimeModules + // jdk.zipfs is used when converting Android extensions, which suggestRuntimeModules does not see + modules("java.instrument", "java.management", "java.net.http", "java.sql", "jdk.dynalink", "jdk.unsupported", "jdk.zipfs") - val iconsRoot = project.file("desktop-icons") + val iconsRoot = project.file("src/desktop-icons") macOS { // iconFile.set(iconsRoot.resolve("icon-mac.icns")) } windows { iconFile.set(iconsRoot.resolve("icon-windows.ico")) - // menuGroup = "Compose Examples" + menu = true + menuGroup = "CloudStream" + shortcut = true + dirChooser = true + // Must never change, otherwise new versions install side by side instead of upgrading // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html - // upgradeUuid = "" + upgradeUuid = "33b76f91-08d2-41b1-9ed1-ddde419f24be" } linux { iconFile.set(iconsRoot.resolve("icon-linux.png")) diff --git a/desktopApp/src/app-resources/common/LICENSE-ASM.txt b/desktopApp/src/app-resources/common/LICENSE-ASM.txt new file mode 100644 index 00000000000..4d191851af4 --- /dev/null +++ b/desktopApp/src/app-resources/common/LICENSE-ASM.txt @@ -0,0 +1,28 @@ + + ASM: a very small and fast Java bytecode manipulation framework + Copyright (c) 2000-2011 INRIA, France Telecom + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + THE POSSIBILITY OF SUCH DAMAGE. diff --git a/desktopApp/src/app-resources/common/LICENSE-Apache-2.0.txt b/desktopApp/src/app-resources/common/LICENSE-Apache-2.0.txt new file mode 100644 index 00000000000..d6456956733 --- /dev/null +++ b/desktopApp/src/app-resources/common/LICENSE-Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000000..2c38da90ba8 --- /dev/null +++ b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,45 @@ +CloudStream for desktop includes third-party software +===================================================== + +CloudStream is free software under the GNU General Public License version 3, +see LICENSE.txt next to this file. + +libmpv (libmpv-2.dll) +--------------------- +Plays video. libmpv-2.dll is built from mpv and the libraries it is compiled +with, among them FFmpeg, libass, libplacebo, HarfBuzz, FreeType, dav1d and +zlib. This build includes GPL-licensed parts, so libmpv-2.dll as a whole is +distributed under the GNU General Public License, version 2 or later, which +allows distribution under version 3 (LICENSE.txt). + +Build: mpv-dev-x86_64-20261004-git-413ff0b1cd (mpv commit 413ff0b1cd), +built with Clang by the libmpv workflow of +https://github.com/harsh-pockt/cloudstream from the build scripts of +https://github.com/shinchiro/mpv-winbuild-cmake, and published at +https://github.com/harsh-pockt/cloudstream/releases/tag/libmpv-20261004-git-413ff0b1cd + +The complete corresponding source code of libmpv-2.dll, with the build +scripts and the exact version of every library in +mpv-dev-x86_64-20261004-git-413ff0b1cd-sources.txt, is published in that +release, which each CloudStream desktop release on GitHub links to. + +Source code: + mpv https://github.com/mpv-player/mpv + Build scripts https://github.com/shinchiro/mpv-winbuild-cmake + FFmpeg https://ffmpeg.org/download.html + +Android extension support +------------------------- +Extensions built only for Android are converted for desktop with these +libraries, which are included as they are: + + dex2jar 2.4.38 Apache License 2.0 + https://github.com/ThexXTURBOXx/dex2jar + Includes code from the Android Open Source Project + (dx), Apache License 2.0 + ASM 9.10.1 BSD 3-Clause License, https://asm.ow2.io + Gson 2.14.0 Apache License 2.0, https://github.com/google/gson + +The Apache License 2.0 is in LICENSE-Apache-2.0.txt next to this file, and +ASM's licence, which also covers the copy of ASM inside dex2jar, is in +LICENSE-ASM.txt. diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt new file mode 100644 index 00000000000..af5e77f45bd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -0,0 +1,456 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.animateContentSize +import com.lagradost.cloudstream4.browse.DockClearance +import com.lagradost.cloudstream4.browse.glass +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream3.APIHolder +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.lagradost.cloudstream4.browse.DetailScreen +import com.lagradost.cloudstream4.browse.DownloadsScreen +import com.lagradost.cloudstream4.download.Downloads +import com.lagradost.cloudstream4.browse.HomeScreen +import com.lagradost.cloudstream4.browse.LibraryScreen +import com.lagradost.cloudstream4.browse.Message +import com.lagradost.cloudstream4.detail.DetailViewModel +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.player.LinksViewModel +import com.lagradost.cloudstream4.player.PlayerScreen +import com.lagradost.cloudstream4.browse.SearchScreen +import com.lagradost.cloudstream4.home.HomeAction +import com.lagradost.cloudstream4.home.HomeViewModel +import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.search.DataStoreSearchHistory +import com.lagradost.cloudstream4.search.SearchAction +import com.lagradost.cloudstream4.search.SearchViewModel +import com.lagradost.cloudstream4.settings.ExtensionsScreen +import com.lagradost.cloudstream4.settings.SettingsHomeScreen +import com.lagradost.cloudstream4.browser.SystemBrowser +import com.lagradost.cloudstream4.torrent.TorrServer +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.consumeAsFlow +import com.mihon.presentation.LocalBackPress +import com.mihon.presentation.settings.SearchableSettings +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +enum class Destination( + val title: StringResource, + val icon: DrawableResource, + val selectedIcon: DrawableResource, +) { + Home(Res.string.title_home, Res.drawable.home_icon_outline_24, Res.drawable.home_icon_filled_24), + Search(Res.string.title_search, Res.drawable.search_icon, Res.drawable.search_icon), + Library(Res.string.library, Res.drawable.library_icon, Res.drawable.library_icon_filled), + Downloads(Res.string.title_downloads, Res.drawable.download_24px, Res.drawable.download_24px), + Settings(Res.string.title_settings, Res.drawable.settings_icon_outline, Res.drawable.settings_icon_filled), +} + +/** A page opened on top of a destination. It owns the view models made for it, cleared when it closes */ +sealed class Page : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + + class Details(val apiName: String, val url: String) : Page() { + /** Play where the title was left as soon as it loads, once: Continue watching does */ + var autoPlay = false + } + class Player(val request: PlayRequest) : Page() +} + +/** + * Navigation for the desktop app: the selected destination, with a dock floating at the bottom to switch, + * with pages such as details and the player opened on top. + * Ctrl+1 to Ctrl+5 switch destination, Ctrl+F opens Search, Escape, Alt+Left or the mouse's back + * button goes back. M pauses the player and hides the app, see [hidden]. + */ +class AppNavigator { + var destination by mutableStateOf(Destination.Home) + private set + + /** Settings pages opened on top of the settings home, last is shown */ + val settingsStack = mutableStateListOf() + + /** Details and player pages, last is shown */ + val pages = mutableStateListOf() + + /** The player fills the screen, without the window frame */ + var fullscreen by mutableStateOf(false) + + /** + * Keys the open player handles before the app's own, whatever has the focus: its buttons come + * and go, so focus cannot be relied on. Returns true for a key it used. + */ + var playerKeys: ((KeyEvent) -> Boolean)? = null + + fun select(destination: Destination) { + closePages() + // Selecting Settings again returns to the settings home + if (destination == Destination.Settings && this.destination == Destination.Settings) settingsStack.clear() + this.destination = destination + } + + fun openDetails(apiName: String, url: String) { + pages.add(Page.Details(apiName, url)) + } + + /** Continue watching: plays the title where it was left, its page under the player to come back to */ + fun resume(apiName: String, url: String) { + pages.add(Page.Details(apiName, url).apply { autoPlay = true }) + } + + fun play(request: PlayRequest) { + pages.add(Page.Player(request)) + } + + fun toggleFullscreen() { + fullscreen = !fullscreen && pages.lastOrNull() is Page.Player + } + + private fun popPage() { + val page = pages.removeAt(pages.lastIndex) + if (page is Page.Player) fullscreen = false + page.viewModelStore.clear() + } + + private fun closePages() { + while (pages.isNotEmpty()) popPage() + } + + fun open(page: SearchableSettings) { + settingsStack.add(page) + } + + /** Settings > Extensions, where plugins are installed */ + fun openExtensions() { + closePages() + destination = Destination.Settings + settingsStack.clear() + settingsStack.add(ExtensionsScreen(::open)) + } + + /** Returns false when there is nothing to go back to */ + fun back(): Boolean { + if (fullscreen) { + fullscreen = false + return true + } + if (pages.isNotEmpty()) { + popPage() + return true + } + if (destination == Destination.Settings && settingsStack.isNotEmpty()) { + settingsStack.removeAt(settingsStack.lastIndex) + return true + } + return false + } + + /** + * The whole app is out of sight, the window gone from the screen, the taskbar and Alt+Tab, after + * M was pressed. The tray icon brings it back. + */ + var hidden by mutableStateOf(false) + + private fun isHideKey(event: KeyEvent) = event.type == KeyEventType.KeyDown && event.key == Key.M && + !event.isCtrlPressed && !event.isAltPressed && !event.isMetaPressed && !event.isShiftPressed + + /** A text field being typed in gets the M press too, which must not hide the app halfway through a word */ + val typing = TypingTracker() + + fun onKeyEvent(event: KeyEvent): Boolean { + if (pages.lastOrNull() is Page.Player && playerKeys?.invoke(event) == true) return true + if (event.type != KeyEventType.KeyDown) return false + if (event.isCtrlPressed) { + if (event.key == Key.F) { + select(Destination.Search) + return true + } + val index = listOf(Key.One, Key.Two, Key.Three, Key.Four, Key.Five).indexOf(event.key) + if (index >= 0) { + select(Destination.entries[index]) + return true + } + } + if (event.key == Key.Escape || (event.isAltPressed && event.key == Key.DirectionLeft)) return back() + return false + } + + /** Keys nothing on the page used. The open player has paused on M already */ + fun onUnhandledKeyEvent(event: KeyEvent): Boolean { + if (!isHideKey(event) || typing.active) return false + hidden = true + return true + } +} + +/** All providers the loaded plugins registered */ +private fun loadedProviders() = APIHolder.apis.withLock { APIHolder.apis.toList() } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AppShell(navigator: AppNavigator) { + val settings = rememberAppSettings() + // Kept for the whole session, so switching destination keeps the results + val home = remember { HomeViewModel(::loadedProviders, settings) } + val search = remember { SearchViewModel(::loadedProviders, settings, DataStoreSearchHistory.instance) } + // Plugins load in the background after the window opens, and the provider settings can change at any time + LaunchedEffect(home) { + combine( + DesktopPluginManager.instance.loadedNames, + settings.provider.extensionLanguages.changes(), + settings.provider.preferredMedia.changes(), + ) { _, _, _ -> }.collect { home.onAction(HomeAction.ProvidersChanged) } + } + + val toasts = remember { SnackbarHostState() } + ExtensionToasts(toasts) + + val page = navigator.pages.lastOrNull() + // The player has its own controls along the bottom, which the dock would cover + val showDock = page !is Page.Player + // What the dock blurs behind its glass + val glass = rememberHazeState() + navigator.typing.Provide { + Box( + Modifier + .fillMaxSize() + // The mouse's back button goes back, as in a browser + .onPointerEvent(PointerEventType.Press) { if (it.button == PointerButton.Back) navigator.back() }, + ) { + Box(Modifier.fillMaxSize().hazeSource(glass)) { + if (page != null) { + CompositionLocalProvider(LocalViewModelStoreOwner provides page) { + PageContent(page, navigator) { query -> + navigator.select(Destination.Search) + search.onAction(SearchAction.SearchFor(query)) + } + } + } else when (navigator.destination) { + Destination.Settings -> { + val settingsPage = navigator.settingsStack.lastOrNull() + ?: remember(navigator) { SettingsHomeScreen(navigator::open) } + val back: (() -> Unit)? = if (navigator.settingsStack.isEmpty()) null else ({ navigator.back() }) + CompositionLocalProvider(LocalBackPress provides back) { + Box(Modifier.padding(bottom = DockClearance)) { settingsPage.Content() } + } + } + + Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails, navigator::resume) + Destination.Search -> SearchScreen(search, navigator::openExtensions, navigator::openDetails) + Destination.Library -> LibraryScreen(DataStoreWatchStore.instance, navigator::openDetails) + Destination.Downloads -> DownloadsScreen(Downloads.instance, navigator::play, navigator::openDetails) + else -> ComingSoon(navigator.destination) + } + } + if (showDock) FloatingDock(navigator, glass, Modifier.align(Alignment.BottomCenter).padding(bottom = 20.dp)) + SnackbarHost(toasts, Modifier.align(Alignment.BottomCenter).padding(start = 16.dp, end = 16.dp, bottom = if (showDock) DockClearance else 16.dp)) + } + } +} + +/** + * Shows the toasts extensions raise, one after another. They can come from any thread, so they are + * queued, and when many arrive at once the oldest are dropped. + */ +@Composable +private fun ExtensionToasts(host: SnackbarHostState) { + val queue = remember { Channel(capacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) } + DisposableEffect(queue) { + DesktopAndroid.toastHandler = { queue.trySend(it) } + onDispose { DesktopAndroid.toastHandler = null } + } + LaunchedEffect(queue) { queue.consumeAsFlow().collect { host.showSnackbar(it) } } +} + +@Composable +private fun PageContent(page: Page, navigator: AppNavigator, onSearch: (query: String) -> Unit) { + val apiName = when (page) { + is Page.Details -> page.apiName + is Page.Player -> page.request.apiName + } + // The provider can be gone if its plugin was uninstalled while the page was open. A download plays without it + val api = remember(apiName) { APIHolder.getApiFromNameNull(apiName) } + if (page is Page.Player && page.request.localFile != null) { + PlayerScreen( + viewModel = viewModel { LinksViewModel(api, page.request) }, + fullscreen = navigator.fullscreen, + onBack = { navigator.back() }, + onToggleFullscreen = navigator::toggleFullscreen, + onKeys = { navigator.playerKeys = it }, + ) + return + } + if (api == null) { + Message("$apiName is not loaded", "Its extension may have been uninstalled.") + return + } + when (page) { + is Page.Details -> DetailScreen( + viewModel = viewModel { DetailViewModel(api, page.url, DataStoreWatchStore.instance, torrents = TorrServer.supported) }, + onBack = { navigator.back() }, + onPlay = navigator::play, + onOpen = navigator::openDetails, + onSearch = onSearch, + autoPlay = page.autoPlay, + onAutoPlayed = { page.autoPlay = false }, + ) + + is Page.Player -> PlayerScreen( + viewModel = viewModel { LinksViewModel(api, page.request, torrents = TorrServer.supported, checks = SystemBrowser.instance) }, + fullscreen = navigator.fullscreen, + onBack = { navigator.back() }, + onToggleFullscreen = navigator::toggleFullscreen, + onKeys = { navigator.playerKeys = it }, + ) + } +} + +/** Placeholder for the destinations built in later phases */ +@Composable +private fun ComingSoon(destination: Destination) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringResource(destination.title), style = MaterialTheme.typography.headlineMedium) + Text( + "Not built yet on desktop", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * The destinations in a pill of frosted glass floating over the bottom of the page, as streaming + * apps have it: the page shows blurred through it. The current one is lit and named, the others + * are icons with their names on hover. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FloatingDock(navigator: AppNavigator, glass: HazeState, modifier: Modifier = Modifier) { + Row( + modifier + .glass(glass, RoundedCornerShape(percent = 50)) + .padding(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Destination.entries.forEach { destination -> + val selected = navigator.destination == destination + val title = stringResource(destination.title) + TooltipArea( + tooltip = { + if (!selected) Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 4.dp) { + Text(title, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) + } + }, + delayMillis = 300, + tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.TopCenter, alignment = Alignment.TopCenter, offset = DpOffset(0.dp, (-8).dp)), + ) { + DockItem(destination, title, selected) { navigator.select(destination) } + } + } + } +} + +@Composable +private fun DockItem(destination: Destination, title: String, selected: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val primary = MaterialTheme.colorScheme.primary + Row( + Modifier + .height(48.dp) + .clip(RoundedCornerShape(percent = 50)) + .background( + when { + selected -> primary.copy(alpha = 0.22f) + hovered -> MaterialTheme.colorScheme.onBackground.copy(alpha = 0.08f) + else -> Color.Transparent + }, + ) + .hoverable(interaction) + .clickable(onClick = onClick) + .animateContentSize() + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(if (selected) destination.selectedIcon else destination.icon), + contentDescription = title, + tint = if (selected) primary else MaterialTheme.colorScheme.onBackground, + ) + if (selected) Text( + title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + modifier = Modifier.padding(start = 8.dp, end = 4.dp), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt new file mode 100644 index 00000000000..075890f268e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt @@ -0,0 +1,91 @@ +package com.lagradost.cloudstream4 + +import java.io.BufferedOutputStream +import java.io.FileOutputStream +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.io.path.createDirectories +import kotlin.io.path.exists + +/** + * Copies everything written to stdout and stderr into logs/cloudstream.log, so the installed app, + * which has no console, still leaves a log behind. The log from the previous run is kept as + * cloudstream.old.log. + */ +object DesktopLog { + fun install(dir: Path) { + try { + dir.createDirectories() + val file = dir.resolve("cloudstream.log") + if (file.exists()) Files.move(file, dir.resolve("cloudstream.old.log"), REPLACE_EXISTING) + + // Buffered: the timestamps are added a byte at a time, which would otherwise mean a write to + // disk per byte. Both streams flush at the end of every line, so the log stays current + val log = TimestampedOutputStream(BufferedOutputStream(FileOutputStream(file.toFile()))) + // Without NiceHttp's trace for every cancelled request, see QuietPrintStream + System.setOut(QuietPrintStream(TeeOutputStream(System.out, log))) + System.setErr(QuietPrintStream(TeeOutputStream(System.err, log))) + } catch (t: Throwable) { + // Logging must never stop the app from starting + System.err.println("Could not create log file in $dir: $t") + } + + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + System.err.println("FATAL Uncaught exception in thread ${thread.name}") + throwable.printStackTrace() + previous?.uncaughtException(thread, throwable) + } + + println( + "INFO DesktopLog: CloudStream ${System.getProperty("jpackage.app-version") ?: "dev"} starting, " + + "Java ${System.getProperty("java.version")}, " + + "${System.getProperty("os.name")} ${System.getProperty("os.version")}" + ) + } + + private class TeeOutputStream(private val a: OutputStream, private val b: OutputStream) : OutputStream() { + override fun write(b: Int) { + a.write(b) + this.b.write(b) + } + + override fun write(bytes: ByteArray, off: Int, len: Int) { + a.write(bytes, off, len) + b.write(bytes, off, len) + } + + override fun flush() { + a.flush() + b.flush() + } + } + + /** Adds a timestamp to the start of every line. Shared by stdout and stderr, so it is synchronized */ + private class TimestampedOutputStream(private val out: OutputStream) : OutputStream() { + private val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS ") + private var lineStart = true + + @Synchronized + override fun write(b: Int) { + if (lineStart) { + out.write(LocalDateTime.now().format(format).toByteArray()) + lineStart = false + } + out.write(b) + if (b == '\n'.code) lineStart = true + } + + @Synchronized + override fun write(bytes: ByteArray, off: Int, len: Int) { + for (i in off until off + len) write(bytes[i].toInt()) + } + + @Synchronized + override fun flush() = out.flush() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 4ff13fe801c..f7a80a634be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -1,62 +1,223 @@ package com.lagradost.cloudstream4 +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindow +import androidx.compose.ui.window.rememberDialogState +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.lagradost.cloudstream4.compose.BlackButton +import coil3.compose.setSingletonImageLoaderFactory +import com.lagradost.cloudstream4.browse.desktopImageLoader import com.lagradost.cloudstream4.compose.LocalFocusOutlineDefault -import com.lagradost.cloudstream4.compose.WhiteButton import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.app_name import com.lagradost.cloudstream4.generated.resources.default_icon -import com.lagradost.cloudstream4.generated.resources.preview +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.lagradost.cloudstream4.browser.SystemBrowser +import com.lagradost.cloudstream4.network.DesktopHttp +import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.settings.AppUpdatePrompt +import com.lagradost.cloudstream4.settings.autoUpdateExtensions +import com.lagradost.cloudstream4.settings.desktopPrimaryColor +import com.lagradost.cloudstream4.settings.desktopThemeMode +import com.lagradost.cloudstream4.theme.BlackButton import com.lagradost.cloudstream4.theme.CloudStreamTheme -import com.lagradost.cloudstream4.theme.CloudStreamThemeMode -import com.mihon.presentation.settings.widget.SwitchPreferenceWidget +import com.lagradost.cloudstream4.theme.WhiteButton +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - title = stringResource(Res.string.app_name), - icon = painterResource(Res.drawable.default_icon) - ) { - CloudStreamTheme(mode = CloudStreamThemeMode.Dark) { - CompositionLocalProvider(LocalFocusOutlineDefault provides false) { - Scaffold( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground - ) { - Column { - Text("Hello, World!") - Row { - WhiteButton("Hello in White") { - } - BlackButton("Hello in Black") { - } - } - var checked by remember { mutableStateOf(false) } - SwitchPreferenceWidget( - title = "hello", subtitle = "world", icon = painterResource( - Res.drawable.preview - ), - checked = checked, - onCheckedChanged = { checked = !checked } +fun main() { + // Before anything opens the app's files, which a reset may delete + val appData = AppData.instance + appData.runPendingReset() + if (appData.isReinstallWithOldData() && askToStartFresh()) appData.reset(AppData.Reset.EVERYTHING) + appData.rememberInstall() + + DesktopLog.install(AppDirs.logs) + // Extensions and the library keep their data in the Android data store, see DataStoreWatchStore + DesktopAndroid.init(AppDirs.root.resolve("android")) + // Before the extensions load: they make their requests through it + @OptIn(DelicateCoroutinesApi::class) + DesktopHttp.start(GlobalScope) + // Edge or Chrome, without a window, stands in for Android's WebView, for extensions behind Cloudflare and the like. + // It only shows a window when the user asks to pass a site's check, and lets that window come in front of the app + SystemBrowser.install()?.onShow = WindowsForeground::allow + // Plugins load in the background so a slow or broken plugin never delays the window + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + val manager = DesktopPluginManager.instance + manager.loadAll() + if (autoUpdateExtensions.get()) { + val updates = runCatching { manager.findUpdates() }.getOrDefault(emptyList()) + if (updates.isNotEmpty()) { + val failed = manager.installUpdates(updates) + println("INFO Main: Updated ${updates.size - failed.size} extensions" + if (failed.isEmpty()) "" else ", failed: $failed") + } + } + } + application { + setSingletonImageLoaderFactory(::desktopImageLoader) + val savedWindow = remember { SavedWindowState(desktopPreferences) } + val windowState = rememberSavedWindowState(savedWindow) + val navigator = remember { AppNavigator() } + var appWindow by remember { mutableStateOf(null) } + val quit = { + // So the window's normal size is saved, not the whole screen + appWindow?.let(WindowsFullscreen::exit) + savedWindow.save(windowState) + exitApplication() + } + val icon = painterResource(Res.drawable.default_icon) + Window( + onCloseRequest = quit, + state = windowState, + visible = WindowsHide.supported || !navigator.hidden, + title = stringResource(Res.string.app_name), + icon = icon, + onPreviewKeyEvent = navigator::onKeyEvent, + onKeyEvent = navigator::onUnhandledKeyEvent, + ) { + appWindow = window + MainContent(navigator, window) + FullscreenEffect(navigator, windowState, window) + LaunchedEffect(navigator.hidden) { + if (WindowsHide.supported) { + if (navigator.hidden) WindowsHide.hide(window) else WindowsHide.show(window) + } + if (navigator.hidden) return@LaunchedEffect + window.toFront() + // A window shown again doesn't hand the keys back to its content by itself, and the + // window itself taking them leaves every shortcut dead + (window.mostRecentFocusOwner ?: window.focusTraversalPolicy?.getDefaultComponent(window))?.requestFocus() + } + } + // While the app is hidden, the only way back. Double click, or right click for the menu + if (navigator.hidden) Tray( + icon = icon, + tooltip = stringResource(Res.string.app_name), + onAction = { navigator.hidden = false }, + menu = { + Item("Show CloudStream", onClick = { navigator.hidden = false }) + Item("Quit", onClick = quit) + }, + ) + } +} + +/** + * After CloudStream was installed again, asks whether to keep the data from before or start fresh. + * Shown in a window of its own before the app opens anything. Closing it keeps the data. + */ +private fun askToStartFresh(): Boolean { + var fresh = false + application(exitProcessOnExit = false) { + DialogWindow( + onCloseRequest = ::exitApplication, + state = rememberDialogState(size = DpSize(480.dp, 260.dp)), + title = "CloudStream", + icon = painterResource(Res.drawable.default_icon), + resizable = false, + ) { + CloudStreamTheme { + ThemedFrame(window) + Surface(color = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground) { + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Welcome back", style = MaterialTheme.typography.titleLarge) + Text( + "CloudStream was installed again and found your data from before: settings, repositories " + + "and extensions. Keep it, or start fresh as if this were a new install?", + modifier = Modifier.weight(1f), ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WhiteButton(onClick = ::exitApplication) { Text("Keep my data") } + BlackButton(onClick = { + fresh = true + exitApplication() + }) { Text("Start fresh") } + } } } } + } + } + return fresh +} + +/** + * Puts the window in full screen while the player asks for it, then back to how it was. On + * Windows without the title bar, elsewhere with Compose's own full screen. + */ +@Composable +private fun FullscreenEffect(navigator: AppNavigator, windowState: WindowState, window: java.awt.Window) { + var before by remember { mutableStateOf(windowState.placement) } + LaunchedEffect(navigator.fullscreen) { + if (WindowsFullscreen.supported) { + if (navigator.fullscreen) WindowsFullscreen.enter(window) else WindowsFullscreen.exit(window) + // The frame comes and goes without the window's size changing when it was maximized, so + // nothing lays it out again: the content kept the frame's place, and the video moved apart + // from the bars drawn around it. Once Windows has taken the frame away or put it back + var waited = 0 + while ((window.insets.top == 0) != navigator.fullscreen && waited++ < 20) delay(25) + window.revalidate() + } else if (navigator.fullscreen) { + before = windowState.placement + windowState.placement = WindowPlacement.Fullscreen + } else if (windowState.placement == WindowPlacement.Fullscreen) { + windowState.placement = before + } + } +} + +@Composable +private fun MainContent(navigator: AppNavigator, window: java.awt.Window) { + val ui = rememberAppSettings().ui + // Changing the theme or color in settings applies right away + val theme by ui.theme.changes().collectAsState(ui.theme.get()) + val primaryColor by ui.primaryColor.changes().collectAsState(ui.primaryColor.get()) + CloudStreamTheme(mode = desktopThemeMode(theme), primaryColor = desktopPrimaryColor(primaryColor)) { + ThemedFrame(window) + CompositionLocalProvider(LocalFocusOutlineDefault provides false) { + Surface( + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + AppShell(navigator) + AppUpdatePrompt() + } } } -} \ No newline at end of file +} + +/** The window's title bar and border in the theme's colours, again whenever the theme changes */ +@Composable +private fun ThemedFrame(window: java.awt.Window) { + val background = MaterialTheme.colorScheme.background + val text = MaterialTheme.colorScheme.onBackground + LaunchedEffect(window, background, text) { WindowsTitleBar.paint(window, background, text) } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt new file mode 100644 index 00000000000..eedea176dc5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt @@ -0,0 +1,80 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import com.mihon.common.preference.PreferenceData.Companion.appStateKey +import com.mihon.common.preference.PreferenceStore +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import java.awt.GraphicsEnvironment +import java.awt.Rectangle + +/** Window size, position and maximized state, kept as app state so it is left out of backups */ +class SavedWindowState(preferences: PreferenceStore) { + private val x = preferences.getFloat(appStateKey("window_x"), Float.NaN) + private val y = preferences.getFloat(appStateKey("window_y"), Float.NaN) + private val width = preferences.getFloat(appStateKey("window_width"), 1280f) + private val height = preferences.getFloat(appStateKey("window_height"), 800f) + private val maximized = preferences.getBoolean(appStateKey("window_maximized"), false) + + fun toWindowState(): WindowState { + val size = DpSize(width.get().coerceAtLeast(MIN_SIZE).dp, height.get().coerceAtLeast(MIN_SIZE).dp) + return WindowState( + placement = if (maximized.get()) WindowPlacement.Maximized else WindowPlacement.Floating, + position = savedPosition(size) ?: WindowPosition.PlatformDefault, + size = size, + ) + } + + fun save(state: WindowState) { + // Full screen is only for watching, the window opens as it was before that + if (state.placement == WindowPlacement.Fullscreen) return + maximized.set(state.placement == WindowPlacement.Maximized) + // Only remember the floating size, otherwise un-maximizing would keep the window full screen + if (state.placement != WindowPlacement.Floating) return + width.set(state.size.width.value) + height.set(state.size.height.value) + val position = state.position + if (position is WindowPosition.Absolute) { + x.set(position.x.value) + y.set(position.y.value) + } + } + + /** The saved position, unless the window would open on a monitor that is no longer connected */ + private fun savedPosition(size: DpSize): WindowPosition? { + val x = x.get() + val y = y.get() + if (x.isNaN() || y.isNaN()) return null + val titleBar = Rectangle(x.toInt(), y.toInt(), size.width.value.toInt(), 40) + val visible = runCatching { + GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices + .any { it.defaultConfiguration.bounds.intersects(titleBar) } + }.getOrDefault(false) + return if (visible) WindowPosition(x.dp, y.dp) else null + } + + private companion object { + const val MIN_SIZE = 400f + } +} + +/** A WindowState that starts where the window was last closed and saves every change */ +@OptIn(FlowPreview::class) +@Composable +fun rememberSavedWindowState(saved: SavedWindowState): WindowState { + val state = remember { saved.toWindowState() } + LaunchedEffect(state) { + snapshotFlow { Triple(state.placement, state.size, state.position) } + .debounce(500) + .collect { saved.save(state) } + } + return state +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/TypingTracker.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/TypingTracker.kt new file mode 100644 index 00000000000..ed278809fdf --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/TypingTracker.kt @@ -0,0 +1,27 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput + +/** + * Knows whether a text field is being typed in. The letters typed into one don't come as key events, + * so a key press can't tell: the text fields' input sessions can. Every text field below [content] + * opens its session through here, and the platform's input still does the work. + */ +class TypingTracker { + private var sessions = 0 + + val active: Boolean get() = sessions > 0 + + @OptIn(ExperimentalComposeUiApi::class) + @Composable + fun Provide(content: @Composable () -> Unit) = InterceptPlatformTextInput({ request, next -> + sessions++ + try { + next.startInputMethod(request) + } finally { + sessions-- + } + }, content) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsElevated.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsElevated.kt new file mode 100644 index 00000000000..64074f6945e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsElevated.kt @@ -0,0 +1,81 @@ +package com.lagradost.cloudstream4 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.WString +import com.sun.jna.win32.StdCallLibrary +import java.awt.KeyboardFocusManager + +/** The shell32 function that starts a program as administrator, see shellapi.h */ +@Suppress("FunctionName") +private interface Shell32 : StdCallLibrary { + fun ShellExecuteExW(info: ShellExecuteInfo): Boolean +} + +@Structure.FieldOrder( + "cbSize", "fMask", "hwnd", "lpVerb", "lpFile", "lpParameters", "lpDirectory", "nShow", + "hInstApp", "lpIDList", "lpClass", "hkeyClass", "dwHotKey", "hIcon", "hProcess", +) +internal class ShellExecuteInfo : Structure() { + @JvmField var cbSize = 0 + @JvmField var fMask = 0 + @JvmField var hwnd: Pointer? = null + @JvmField var lpVerb: WString? = null + @JvmField var lpFile: WString? = null + @JvmField var lpParameters: WString? = null + @JvmField var lpDirectory: WString? = null + @JvmField var nShow = 0 + @JvmField var hInstApp: Pointer? = null + @JvmField var lpIDList: Pointer? = null + @JvmField var lpClass: WString? = null + @JvmField var hkeyClass: Pointer? = null + @JvmField var dwHotKey = 0 + @JvmField var hIcon: Pointer? = null + @JvmField var hProcess: Pointer? = null +} + +private val shell32: Shell32? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Native.load("shell32", Shell32::class.java) } + .onFailure { println("ERROR Shell32: shell32 could not be loaded: $it") } + .getOrNull() +} + +class ElevationDeclinedException : Exception("Windows was not given permission to install the update") + +/** + * Starts a program as administrator. Windows asks the user first, and shows that prompt in front + * only when the program asking is in front: one started by the app after it quit asks from the + * taskbar, unseen, and Windows cancels it two minutes later. So the app asks while it is open, and + * this returns once the user has answered. + */ +object WindowsElevated { + private const val SEE_MASK_NOASYNC = 0x100 + // Without it Windows shows its own error box, and then reports any failure as cancelled + private const val SEE_MASK_FLAG_NO_UI = 0x400 + private const val SW_SHOWNORMAL = 1 + private const val ERROR_CANCELLED = 1223 + + /** Starts [command], throwing [ElevationDeclinedException] when the user says no */ + fun start(command: List) { + val shell32 = shell32 ?: run { + ProcessBuilder(command).start() + return + } + val info = ShellExecuteInfo().apply { + cbSize = size() + fMask = SEE_MASK_NOASYNC or SEE_MASK_FLAG_NO_UI + hwnd = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow?.let { Native.getWindowPointer(it) } + lpVerb = WString("runas") + lpFile = WString(command.first()) + lpParameters = WString(command.drop(1).joinToString(" ") { if (' ' in it) "\"$it\"" else it }) + nShow = SW_SHOWNORMAL + } + if (!shell32.ShellExecuteExW(info)) { + val error = Native.getLastError() + if (error == ERROR_CANCELLED) throw ElevationDeclinedException() + throw IllegalStateException("${command.first()} could not be started, Windows error $error") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt new file mode 100644 index 00000000000..d32a557ae2f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt @@ -0,0 +1,141 @@ +package com.lagradost.cloudstream4 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.win32.StdCallLibrary +import java.awt.Window + +/** The user32 functions full screen needs, see winuser.h */ +@Suppress("FunctionName") +private interface User32 : StdCallLibrary { + fun GetWindowLongPtrW(hwnd: Pointer, index: Int): Long + fun SetWindowLongPtrW(hwnd: Pointer, index: Int, value: Long): Long + fun GetWindowRect(hwnd: Pointer, rect: Rect): Boolean + fun SetWindowPos(hwnd: Pointer, after: Pointer?, x: Int, y: Int, cx: Int, cy: Int, flags: Int): Boolean + fun MonitorFromWindow(hwnd: Pointer, flags: Int): Pointer? + fun GetMonitorInfoW(monitor: Pointer, info: MonitorInfo): Boolean + fun ShowWindow(hwnd: Pointer, cmd: Int): Boolean + fun IsZoomed(hwnd: Pointer): Boolean + fun SetForegroundWindow(hwnd: Pointer): Boolean + fun AllowSetForegroundWindow(processId: Int): Boolean +} + +private val user32: User32? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Native.load("user32", User32::class.java) } + .onFailure { println("ERROR User32: user32 could not be loaded: $it") } + .getOrNull() +} + +@Structure.FieldOrder("left", "top", "right", "bottom") +internal class Rect : Structure() { + @JvmField var left = 0 + @JvmField var top = 0 + @JvmField var right = 0 + @JvmField var bottom = 0 +} + +@Structure.FieldOrder("cbSize", "monitor", "work", "flags") +internal class MonitorInfo : Structure() { + @JvmField var cbSize = size() + @JvmField var monitor = Rect() + @JvmField var work = Rect() + @JvmField var flags = 0 +} + +/** + * Full screen the way browsers and video players do it on Windows: the window loses its title bar + * and border and covers the whole monitor, taskbar included. Compose's own full screen keeps the + * title bar of a decorated window. The window stays the same native window throughout, so the + * video playing in it carries on. + */ +object WindowsFullscreen { + private const val GWL_STYLE = -16 + private const val WS_CAPTION = 0x00C00000L + private const val WS_THICKFRAME = 0x00040000L + private const val MONITOR_DEFAULTTONEAREST = 2 + private const val SWP_NOZORDER = 0x0004 + private const val SWP_NOACTIVATE = 0x0010 + private const val SWP_FRAMECHANGED = 0x0020 + private const val SW_MAXIMIZE = 3 + private const val SW_RESTORE = 9 + + /** Whether this platform has it, otherwise Compose's own full screen is used */ + val supported: Boolean get() = user32 != null + + private class Saved(val style: Long, val rect: Rect, val maximized: Boolean) + + private var saved: Saved? = null + + val active: Boolean get() = saved != null + + fun enter(window: Window) { + val user32 = user32 ?: return + if (saved != null) return + val hwnd = Native.getWindowPointer(window) ?: return + // A maximized window is restored first: Windows keeps its maximized size otherwise, and on the + // way back the content would stay the size of the whole screen, under the taskbar + val maximized = user32.IsZoomed(hwnd) + if (maximized) user32.ShowWindow(hwnd, SW_RESTORE) + val rect = Rect().also { user32.GetWindowRect(hwnd, it) } + val style = user32.GetWindowLongPtrW(hwnd, GWL_STYLE) + val info = MonitorInfo() + val monitor = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) ?: return + if (!user32.GetMonitorInfoW(monitor, info)) return + saved = Saved(style, rect, maximized) + user32.SetWindowLongPtrW(hwnd, GWL_STYLE, style and (WS_CAPTION or WS_THICKFRAME).inv()) + val m = info.monitor + user32.SetWindowPos(hwnd, null, m.left, m.top, m.right - m.left, m.bottom - m.top, SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED) + } + + fun exit(window: Window) { + val user32 = user32 ?: return + val before = saved ?: return + saved = null + val hwnd = Native.getWindowPointer(window) ?: return + user32.SetWindowLongPtrW(hwnd, GWL_STYLE, before.style) + val r = before.rect + user32.SetWindowPos(hwnd, null, r.left, r.top, r.right - r.left, r.bottom - r.top, SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED) + // Maximizing again lets Windows size it to the screen without the taskbar, as before + if (before.maximized) user32.ShowWindow(hwnd, SW_MAXIMIZE) + } +} + +/** + * Hides the whole window, gone from the screen, the taskbar and Alt+Tab, and shows it again as it + * was. Compose's own `visible` brings a maximized window back the size of the whole screen, with its + * bottom under the taskbar. + */ +object WindowsHide { + private const val SW_HIDE = 0 + private const val SW_SHOW = 5 + + val supported: Boolean get() = user32 != null + + var hidden = false + private set + + fun hide(window: Window) { + val user32 = user32 ?: return + val hwnd = Native.getWindowPointer(window) ?: return + user32.ShowWindow(hwnd, SW_HIDE) + hidden = true + } + + fun show(window: Window) { + val user32 = user32 ?: return + if (!hidden) return + hidden = false + val hwnd = Native.getWindowPointer(window) ?: return + user32.ShowWindow(hwnd, SW_SHOW) + user32.SetForegroundWindow(hwnd) + } +} + +/** Lets another program's window come in front of the app's, which Windows otherwise refuses it */ +object WindowsForeground { + fun allow(processId: Long) { + user32?.AllowSetForegroundWindow(processId.toInt()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsTitleBar.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsTitleBar.kt new file mode 100644 index 00000000000..2120e487550 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsTitleBar.kt @@ -0,0 +1,56 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.ptr.IntByReference +import com.sun.jna.win32.StdCallLibrary +import java.awt.Window + +/** The dwmapi function the title bar needs, see dwmapi.h */ +@Suppress("FunctionName") +private interface DwmApi : StdCallLibrary { + fun DwmSetWindowAttribute(hwnd: Pointer, attribute: Int, value: IntByReference, size: Int): Int +} + +private val dwmapi: DwmApi? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Native.load("dwmapi", DwmApi::class.java) } + .onFailure { println("ERROR DwmApi: dwmapi could not be loaded: $it") } + .getOrNull() +} + +/** + * Paints the window's title bar and border, which Windows draws, in the theme's colours, so the + * frame matches the page inside it instead of staying white. Windows 11 takes the exact colours, + * Windows 10 only switches the title bar to dark for a dark theme. + */ +object WindowsTitleBar { + private const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + private const val DWMWA_BORDER_COLOR = 34 + private const val DWMWA_CAPTION_COLOR = 35 + private const val DWMWA_TEXT_COLOR = 36 + + fun paint(window: Window, background: Color, text: Color) { + // Also what shows for a moment where the window grows, before the page is drawn there again + window.background = java.awt.Color(background.toArgb(), false) + val dwm = dwmapi ?: return + val hwnd = Native.getWindowPointer(window) ?: return + fun set(attribute: Int, value: Int) = dwm.DwmSetWindowAttribute(hwnd, attribute, IntByReference(value), 4) + set(DWMWA_USE_IMMERSIVE_DARK_MODE, if (background.luminance() < 0.5f) 1 else 0) + set(DWMWA_CAPTION_COLOR, background.colorRef()) + set(DWMWA_BORDER_COLOR, background.colorRef()) + set(DWMWA_TEXT_COLOR, text.colorRef()) + } + + /** A COLORREF, 0x00BBGGRR */ + private fun Color.colorRef(): Int { + val argb = toArgb() + val r = argb shr 16 and 0xFF + val g = argb shr 8 and 0xFF + val b = argb and 0xFF + return (b shl 16) or (g shl 8) or r + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt new file mode 100644 index 00000000000..1a4cbbd3b33 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -0,0 +1,781 @@ +package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpOffset +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream4.generated.resources.baseline_theaters_24 +import com.lagradost.cloudstream4.generated.resources.check +import com.lagradost.cloudstream4.generated.resources.content_copy_24px +import com.lagradost.cloudstream4.generated.resources.ic_baseline_add_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_replay_24 +import com.lagradost.cloudstream4.generated.resources.open_in_new_24px +import com.lagradost.cloudstream4.generated.resources.person_24px +import java.awt.Desktop +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.net.URI +import kotlinx.coroutines.delay +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.download.DownloadRequest +import com.lagradost.cloudstream4.download.DownloadSource +import com.lagradost.cloudstream4.download.DownloadedEpisode +import com.lagradost.cloudstream4.download.DownloadedTitle +import com.lagradost.cloudstream4.download.Downloads +import com.lagradost.cloudstream4.detail.DetailAction +import com.lagradost.cloudstream4.detail.DetailState +import com.lagradost.cloudstream4.detail.DetailStatus +import com.lagradost.cloudstream4.detail.DetailViewModel +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.arrow_back +import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import com.lagradost.cloudstream4.generated.resources.search_icon +import com.lagradost.cloudstream4.library.PlaybackPosition +import com.lagradost.cloudstream4.library.WatchType +import com.lagradost.cloudstream4.theme.AppFilterChip +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton +import org.jetbrains.compose.resources.painterResource + +/** The details of a movie or series, with its episodes and similar titles */ +@Composable +fun DetailScreen( + viewModel: DetailViewModel, + onBack: () -> Unit, + onPlay: (PlayRequest) -> Unit, + onOpen: (apiName: String, url: String) -> Unit, + /** Looks for the title in every extension, from the Search page */ + onSearch: (query: String) -> Unit, + /** Play where the title was left as soon as it loads, from Continue watching */ + autoPlay: Boolean = false, + onAutoPlayed: () -> Unit = {}, + downloads: Downloads = Downloads.instance, +) { + val state by viewModel.state.collectAsState() + Box(Modifier.fillMaxSize()) { + when (val status = state.status) { + DetailStatus.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + is DetailStatus.Failed -> Message("Could not load this title", status.message) { + WhiteButton(onClick = { viewModel.onAction(DetailAction.Retry) }) { Text("Try again") } + } + + DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, onSearch, downloads, autoPlay, onAutoPlayed) + } + // Over the backdrop, in a disc of the page's colour so it shows on any picture + IconButton( + onClick = onBack, + modifier = Modifier + .padding(12.dp) + .shadow(8.dp, CircleShape) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f), CircleShape), + ) { + Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back") + } + } +} + +@Composable +private fun Details( + state: DetailState, + viewModel: DetailViewModel, + onPlay: (PlayRequest) -> Unit, + onOpen: (apiName: String, url: String) -> Unit, + onSearch: (query: String) -> Unit, + downloads: Downloads, + autoPlay: Boolean, + onAutoPlayed: () -> Unit, +) { + val response = state.response ?: return + val season = state.seasons.getOrNull(state.selectedSeason) + val downloadStates by downloads.states.collectAsState() + // A downloaded movie or episode plays its file, without finding links + fun play(request: PlayRequest?, episode: Episode?) { + request ?: return + val id = downloadId(state, episode) + val file = id?.let(downloads::file) + onPlay(if (file == null) request else request.copy(localFile = file.absolutePath, localSubtitles = localSubtitles(downloads, id))) + } + // Once only, so coming back from the player leaves the page as it is + LaunchedEffect(Unit) { + if (!autoPlay) return@LaunchedEffect + onAutoPlayed() + val request = viewModel.resumeRequest() ?: return@LaunchedEffect + play(request, state.resume?.episode) + } + fun downloadButton(episode: Episode?): @Composable () -> Unit = { + val id = downloadId(state, episode) + val request = downloadRequest(state, episode) + // The source is picked first, as on Android + var picking by remember { mutableStateOf(false) } + if (id != null && request != null) { + DownloadButton( + state = downloadStates[id], + onStart = { picking = true }, + onPause = { downloads.pause(id) }, + onResume = { downloads.start(request) }, + onDelete = { state.titleId?.let { downloads.delete(it, id) } }, + onPlay = { play(viewModel.playRequest(episode), episode) }, + ) + if (picking) DownloadSourcePicker( + source = request.source, + onPick = { link, subtitles -> + picking = false + downloads.start(request.copy(link = link, subtitles = subtitles)) + }, + onDismiss = { picking = false }, + ) + } + } + BoxWithConstraints(Modifier.fillMaxSize()) { + // Episode cards fill the width, about 440 dp each + val columns = ((maxWidth - PageGutter * 2) / 440.dp).toInt().coerceIn(1, 4) + LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { + item(key = "hero") { + Hero( + response = response, + state = state, + onPlay = { fromStart -> play(viewModel.playRequest(fromStart = fromStart), null) }, + onResume = { episode -> play(viewModel.playRequest(episode), episode) }, + onBookmark = { viewModel.onAction(DetailAction.SetBookmark(it)) }, + onSearch = { onSearch(response.name) }, + download = if (state.movieData != null && response.type != TvType.Live) downloadButton(null) else null, + ) + } + + if (season != null || state.dubs.size > 1 || state.seasons.size > 1) item(key = "episodes") { + Column(Modifier.padding(horizontal = PageGutter).padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + SectionTitle("Episodes", season?.let { "${it.label} · ${it.episodes.size} episodes" }) + Choices(state, viewModel::onAction) + } + } + if (season != null) { + items(season.episodes.chunked(columns)) { row -> + Row( + Modifier.padding(horizontal = PageGutter, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + row.forEach { episode -> + EpisodeCard(episode, state.progressOf(episode), downloadButton(episode), Modifier.weight(1f)) { + play(viewModel.playRequest(episode), episode) + } + } + // The last row's cards keep the others' width + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + + val actors = response.actors.orEmpty() + if (actors.isNotEmpty()) item(key = "cast") { CastRow(actors) } + + val recommendations = response.recommendations.orEmpty() + if (recommendations.isNotEmpty()) item(key = "more") { + PosterRow( + title = "More like this", + items = recommendations, + horizontal = false, + modifier = Modifier.padding(top = 24.dp), + onItemClick = { onOpen(it.apiName, it.url) }, + ) + } + + item(key = "about") { About(response) } + } + } +} + +/** "12:30", or "1:02:03" from an hour on */ +fun formatTime(ms: Long): String { + val total = ms / 1000 + val (h, m, s) = Triple(total / 3600, total / 60 % 60, total % 60) + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) +} + +/** A section's title, with a line of grey after it */ +@Composable +private fun SectionTitle(title: String, detail: String? = null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RowTitle(title) + detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } +} + +/** + * The top of the page, as on Home: the title's wide picture across most of the window, faded into + * the page, with its poster, name or logo, facts, genres and story, then what can be done with it. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LazyItemScope.Hero( + response: LoadResponse, + state: DetailState, + onPlay: (fromStart: Boolean) -> Unit, + onResume: (Episode) -> Unit, + onBookmark: (WatchType) -> Unit, + onSearch: () -> Unit, + /** The movie's download button, null for a series or a live stream */ + download: (@Composable () -> Unit)?, +) { + val background = MaterialTheme.colorScheme.background + Box(Modifier.fillParentMaxWidth().heightIn(min = 460.dp).fillParentMaxHeight(0.74f)) { + RemoteImage( + response.backgroundPosterUrl?.takeIf { it.isNotBlank() } ?: response.posterUrl, + response.posterHeaders, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + corner = 0.dp, + ) + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to background.copy(alpha = 0.25f), 0.5f to Color.Transparent, 1f to background))) + // Strong on the side of the text, so it reads on a bright picture + Box(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to background.copy(alpha = 0.95f), 0.45f to background.copy(alpha = 0.75f), 0.8f to background.copy(alpha = 0.1f), 1f to Color.Transparent))) + Row( + Modifier.align(Alignment.BottomStart).padding(start = PageGutter, end = PageGutter, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(28.dp), + verticalAlignment = Alignment.Bottom, + ) { + RemoteImage( + response.posterUrl, + response.posterHeaders, + contentDescription = response.name, + modifier = Modifier.width(180.dp).height(270.dp).shadow(24.dp, RoundedCornerShape(8.dp)), + ) + Column(Modifier.widthIn(max = 760.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + TitleOrLogo(response) + Facts(response, state) + response.tags?.takeIf { it.isNotEmpty() }?.let { tags -> + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + tags.take(6).forEach { Tag(it) } + } + } + response.plot?.takeIf { it.isNotBlank() }?.let { Plot(it) } + state.unsupported?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (state.unsupported == null && state.movieData == null && state.seasons.isEmpty()) { + Text("No episodes yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Actions(response, state, onPlay, onResume, onBookmark, onSearch, download) + WatchedSoFar(state) + } + } + } +} + +/** The title's logo when the extension gives one, its name otherwise or when the logo does not load */ +@Composable +private fun TitleOrLogo(response: LoadResponse) { + var logoFailed by remember(response.logoUrl) { mutableStateOf(false) } + val logo = response.logoUrl?.takeIf { it.isNotBlank() && !logoFailed } + if (logo != null) AsyncImage( + model = logo, + contentDescription = response.name, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + onError = { logoFailed = true }, + modifier = Modifier.fillMaxWidth(0.7f).height(110.dp), + ) else Text( + response.name, + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) +} + +/** "★ 7.8 · 2024 · Movie · 112 min · PG-13 · Ongoing · Provider", the score in the accent */ +@Composable +private fun Facts(response: LoadResponse, state: DetailState) { + val grey = MaterialTheme.colorScheme.onSurfaceVariant + val facts = listOfNotNull( + response.year?.toString(), + typeName(response.type), + response.duration?.let { "$it min" }, + state.seasons.size.takeIf { it > 1 }?.let { "$it seasons" }, + response.contentRating?.takeIf { it.isNotBlank() }, + (response as? EpisodeResponse)?.showStatus?.name, + response.apiName, + ) + Text( + buildAnnotatedString { + response.score?.let { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)) { + append("★ ${"%.1f".format(it.toFloat(10))}") + } + if (facts.isNotEmpty()) append(" · ") + } + append(facts.joinToString(" · ")) + }, + style = MaterialTheme.typography.bodyMedium, + color = grey, + ) +} + +/** A genre, as a small pill */ +@Composable +private fun Tag(text: String) { + Text( + text, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.10f)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +/** The story, three lines, all of it on a click */ +@Composable +private fun Plot(plot: String) { + var expanded by remember(plot) { mutableStateOf(false) } + var overflows by remember(plot) { mutableStateOf(false) } + Column { + Text( + plot, + style = MaterialTheme.typography.bodyLarge, + maxLines = if (expanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + onTextLayout = { if (!expanded) overflows = it.hasVisualOverflow }, + ) + if (overflows || expanded) Text( + if (expanded) "Less" else "More", + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp).clip(RoundedCornerShape(4.dp)).clickable { expanded = !expanded }.padding(2.dp), + ) + } +} + +/** + * Play or resume, large in the accent; the library and download; then small round tools: other + * sources, the trailer, the title on the provider's site and copying its link + */ +@Composable +private fun Actions( + response: LoadResponse, + state: DetailState, + onPlay: (fromStart: Boolean) -> Unit, + onResume: (Episode) -> Unit, + onBookmark: (WatchType) -> Unit, + onSearch: () -> Unit, + download: (@Composable () -> Unit)?, +) { + val big = PaddingValues(horizontal = 28.dp, vertical = 12.dp) + Row( + Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val resume = state.resume + if (state.unsupported == null) when { + // Continue watching left off in an episode + resume?.episode != null -> WhiteButton(onClick = { onResume(resume.episode!!) }, contentPadding = big) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + val verb = if (resume.positionMs > 0) "Resume" else "Play" + Text("$verb ${resume.label}", modifier = Modifier.padding(start = 8.dp)) + } + + state.movieData != null -> { + WhiteButton(onClick = { onPlay(false) }, contentPadding = big) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + Text(if (resume != null) "Resume" else "Play", modifier = Modifier.padding(start = 8.dp)) + } + if (resume != null) BlackButton(onClick = { onPlay(true) }) { + Icon(painterResource(Res.drawable.ic_baseline_replay_24), contentDescription = null, modifier = Modifier.size(20.dp)) + Text("Start over", modifier = Modifier.padding(start = 8.dp)) + } + } + + // A series not started yet begins at its first episode + else -> state.seasons.firstOrNull()?.episodes?.firstOrNull()?.let { first -> + WhiteButton(onClick = { onResume(first) }, contentPadding = big) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + val label = listOfNotNull(first.season?.let { "S$it" }, first.episode?.let { "E$it" }).joinToString(" ") + Text(if (label.isEmpty()) "Play" else "Play $label", modifier = Modifier.padding(start = 8.dp)) + } + } + } + LibraryButton(state.bookmark, onBookmark) + download?.let { RoundTool("Download") { it() } } + RoundTool("Find in other sources", onClick = onSearch) { + Icon(painterResource(Res.drawable.search_icon), contentDescription = null, modifier = Modifier.size(20.dp)) + } + response.trailers.firstOrNull()?.let { trailer -> + RoundTool("Watch the trailer", onClick = { openInBrowser(trailer.extractorUrl) }) { + Icon(painterResource(Res.drawable.baseline_theaters_24), contentDescription = null, modifier = Modifier.size(20.dp)) + } + } + RoundTool("Open on ${response.apiName}'s site", onClick = { openInBrowser(response.url) }) { + Icon(painterResource(Res.drawable.open_in_new_24px), contentDescription = null, modifier = Modifier.size(20.dp)) + } + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + RoundTool(if (copied) "Link copied" else "Copy the link", onClick = { + copyText(response.url) + copied = true + }) { + Icon( + painterResource(if (copied) Res.drawable.check else Res.drawable.content_copy_24px), + contentDescription = null, + tint = if (copied) MaterialTheme.colorScheme.primary else LocalContentColor.current, + modifier = Modifier.size(20.dp), + ) + } + } +} + +/** How far the movie, or the episode to resume, was watched: a thin bar and the time left */ +@Composable +private fun WatchedSoFar(state: DetailState) { + val resume = state.resume ?: return + val progress = resume.episode?.let(state::progressOf) ?: state.titleId?.let(state.progress::get) ?: return + if (progress.watched || progress.positionMs <= 0 || progress.durationMs <= 0) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LinearProgressIndicator( + progress = { progress.fraction }, + modifier = Modifier.width(220.dp).height(4.dp), + trackColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.15f), + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + drawStopIndicator = {}, + ) + val left = (progress.durationMs - progress.positionMs) / 60_000 + Text( + if (left > 0) "$left min left" else "Almost done", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** A round button of the theme's tonal colour, its name on hover */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RoundTool(name: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) { + TooltipArea( + tooltip = { GlassTooltip(name) }, + delayMillis = 300, + tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.BottomCenter, alignment = Alignment.BottomCenter, offset = DpOffset(0.dp, 8.dp)), + ) { + Box( + Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center, + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { content() } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (state.dubs.size > 1) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + state.dubs.forEach { dub -> + AppFilterChip( + selected = dub == state.selectedDub, + onClick = { onAction(DetailAction.SelectDub(dub)) }, + label = { Text(if (dub == DubStatus.None) "Default" else dub.name) }, + ) + } + } + if (state.seasons.size > 1) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + state.seasons.forEachIndexed { index, season -> + AppFilterChip( + selected = index == state.selectedSeason, + onClick = { onAction(DetailAction.SelectSeason(index)) }, + label = { Text(season.label) }, + ) + } + } + } +} + +/** Which library list the title is in, with a menu to change it */ +@Composable +private fun LibraryButton(current: WatchType, onSelect: (WatchType) -> Unit) { + var open by remember { mutableStateOf(false) } + val colors = MaterialTheme.colorScheme + Box { + BlackButton(onClick = { open = true }) { + Icon( + painterResource(if (current == WatchType.NONE) Res.drawable.ic_baseline_add_24 else Res.drawable.check), + contentDescription = null, + tint = if (current == WatchType.NONE) LocalContentColor.current else colors.primary, + modifier = Modifier.size(20.dp), + ) + Text(if (current == WatchType.NONE) "My library" else current.label, modifier = Modifier.padding(start = 8.dp)) + } + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + offset = DpOffset(0.dp, 8.dp), + shape = RoundedCornerShape(16.dp), + containerColor = colors.surfaceContainer, + tonalElevation = 0.dp, + shadowElevation = 16.dp, + border = BorderStroke(1.dp, colors.onBackground.copy(alpha = 0.08f)), + ) { + WatchType.lists.forEach { type -> + val selected = type == current + DropdownMenuItem( + text = { Text(type.label, color = if (selected) colors.primary else colors.onBackground, fontWeight = if (selected) FontWeight.SemiBold else null) }, + trailingIcon = if (selected) { + { Icon(painterResource(Res.drawable.check), contentDescription = null, tint = colors.primary, modifier = Modifier.size(18.dp)) } + } else null, + onClick = { + open = false + onSelect(type) + }, + modifier = Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)), + ) + } + if (current != WatchType.NONE) DropdownMenuItem( + text = { Text("Remove from library", color = colors.error) }, + onClick = { + open = false + onSelect(WatchType.NONE) + }, + modifier = Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)), + ) + } + } +} + +/** + * An episode as a card in the grid: its picture with how far it was watched along the bottom and a + * play sign on hover, its name, story and length, and its download button + */ +@Composable +private fun EpisodeCard( + episode: Episode, + progress: PlaybackPosition?, + download: @Composable () -> Unit, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val colors = MaterialTheme.colorScheme + Row( + modifier + .height(104.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (hovered) colors.surfaceVariant else colors.surfaceContainer) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(156.dp).fillMaxHeight().clip(RoundedCornerShape(8.dp)).background(colors.surface)) { + if (episode.posterUrl != null) RemoteImage(episode.posterUrl, null, contentDescription = null, modifier = Modifier.fillMaxSize(), corner = 0.dp) + if (hovered || episode.posterUrl == null) Box( + Modifier.align(Alignment.Center).size(36.dp).background(Color.Black.copy(alpha = 0.55f), CircleShape), + contentAlignment = Alignment.Center, + ) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp)) } + if (progress != null && progress.positionMs > 0) { + val fraction = if (progress.watched) 1f else progress.fraction + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(3.dp).background(Color.Black.copy(alpha = 0.4f))) + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth(fraction).height(3.dp).background(colors.primary)) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + DetailViewModel.episodeLabel(episode), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + episode.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + val meta = when { + progress?.watched == true -> "Watched ✓" + progress != null && progress.positionMs > 0 -> "${formatTime(progress.positionMs)} / ${formatTime(progress.durationMs)}" + else -> episode.runTime?.let { "$it min" } + } + meta?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = if (progress?.watched == true) colors.primary else colors.onSurfaceVariant) + } + } + download() + } +} + +/** The people in it, as round pictures with their names and parts */ +@Composable +private fun CastRow(actors: List) { + Column(Modifier.padding(top = 24.dp)) { + RowTitle("Cast", Modifier.padding(horizontal = PageGutter, vertical = 4.dp)) + ScrollingRow { + items(actors) { data -> + Column(Modifier.width(96.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(80.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surface), contentAlignment = Alignment.Center) { + Icon(painterResource(Res.drawable.person_24px), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(36.dp)) + data.actor.image?.takeIf { it.isNotBlank() }?.let { + RemoteImage(it, null, contentDescription = data.actor.name, modifier = Modifier.fillMaxSize(), corner = 40.dp) + } + } + Text(data.actor.name, style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + data.roleString?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + } + } + } + } + } +} + +/** Everything known about the title, in a card at the bottom of the page */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun About(response: LoadResponse) { + val facts = listOfNotNull( + "Provider" to response.apiName, + "Type" to typeName(response.type), + response.year?.let { "Year" to it.toString() }, + response.duration?.let { "Runtime" to "$it min" }, + response.contentRating?.takeIf { it.isNotBlank() }?.let { "Age rating" to it }, + (response as? EpisodeResponse)?.showStatus?.let { "Status" to it.name }, + response.score?.let { "Score" to "%.1f / 10".format(it.toFloat(10)) }, + response.tags?.takeIf { it.isNotEmpty() }?.let { "Genres" to it.joinToString(", ") }, + ) + Column(Modifier.padding(start = PageGutter, end = PageGutter, top = 28.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + RowTitle("About") + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainer).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + facts.forEach { (label, value) -> + Column(Modifier.widthIn(min = 120.dp, max = 360.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium) + } + } + } + Column { + Text("Link", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + response.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clip(RoundedCornerShape(4.dp)).clickable { openInBrowser(response.url) }, + ) + } + } + } +} + +/** "Movie", "Series", "Anime"... */ +private fun typeName(type: TvType): String = when (type) { + TvType.TvSeries -> "Series" + TvType.AsianDrama -> "Asian drama" + TvType.AnimeMovie -> "Anime movie" + TvType.OVA -> "OVA" + else -> type.name.replace(Regex("(?<=[a-z])(?=[A-Z])"), " ").lowercase().replaceFirstChar { it.uppercase() } +} + +private fun openInBrowser(url: String) { + runCatching { Desktop.getDesktop().browse(URI(url)) } + .onFailure { println("WARN DetailScreen: could not open $url: $it") } +} + +private fun copyText(text: String) { + runCatching { Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null) } +} + +/** The id a download is saved under: the episode's, or the title's for a movie */ +private fun downloadId(state: DetailState, episode: Episode?): Int? = + if (episode == null) state.titleId else state.episodeIds[episode] + +/** What downloads an episode, or the movie when [episode] is null */ +private fun downloadRequest(state: DetailState, episode: Episode?): DownloadRequest? { + val response = state.response ?: return null + val titleId = state.titleId ?: return null + val id = downloadId(state, episode) ?: return null + val data = episode?.data ?: state.movieData ?: return null + // An episode without a number is numbered by its place, so two never get the same file name + val number = episode?.let { it.episode ?: (state.seasons.flatMap { season -> season.episodes }.indexOf(it) + 1) } + return DownloadRequest( + DownloadedTitle(titleId, response.apiName, response.url, response.name, response.type, response.posterUrl), + DownloadedEpisode(id, titleId, episode?.name, number, episode?.season, episode?.posterUrl ?: response.posterUrl, episode?.description), + DownloadSource(response.apiName, data), + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt new file mode 100644 index 00000000000..d1e52bd7179 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt @@ -0,0 +1,124 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream4.download.DownloadSource +import com.lagradost.cloudstream4.download.Downloads +import com.lagradost.cloudstream4.providers.loadLinksSafely +import com.lagradost.cloudstream4.theme.AppShapes +import com.lagradost.cloudstream4.theme.BlackButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.net.URI + +/** + * Asks which source to download from, as the Android app does: the best quality can be a file of + * many gigabytes, and a source's name often tells its size. The sources show as they are found, + * best quality first, so one can be picked before every extractor is done. + */ +@Composable +fun DownloadSourcePicker( + source: DownloadSource, + onPick: (ExtractorLink, List) -> Unit, + onDismiss: () -> Unit, +) { + // Links come from the extractors' own threads + val found = remember(source) { MutableStateFlow>(emptyList()) } + val subtitles = remember(source) { MutableStateFlow>(emptyList()) } + var finding by remember(source) { mutableStateOf(true) } + var error by remember(source) { mutableStateOf(null) } + LaunchedEffect(source) { + val api = APIHolder.getApiFromNameNull(source.apiName) + if (api == null) { + error = "${source.apiName} is not loaded" + } else withContext(Dispatchers.IO) { + api.loadLinksSafely( + source.data, + onSubtitle = { sub -> subtitles.update { if (it.any { s -> s.url == sub.url }) it else it + sub } }, + // An extractor can still call back once the picker is closed + onLink = { link -> if (isActive) found.update { Downloads.forPicking(it + link) } }, + ) + } + finding = false + } + val links by found.collectAsState() + + AlertDialog( + // dialog__window_background.xml + containerColor = MaterialTheme.colorScheme.background, + shape = AppShapes.dialog, + onDismissRequest = onDismiss, + title = { Text("Download from") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (finding) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + when { + error != null -> error!! + finding -> "Finding sources… ${links.size} found" + links.isEmpty() -> "No source can be downloaded" + else -> "${links.size} sources" + }, + style = MaterialTheme.typography.bodySmall, + color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyColumn(Modifier.width(520.dp).heightIn(max = 440.dp)) { + items(links, key = { it.url }) { link -> + Column( + Modifier.fillMaxWidth().clickable { onPick(link, subtitles.value) }.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(link.name.trim(), style = MaterialTheme.typography.bodyMedium, maxLines = 3, overflow = TextOverflow.Ellipsis) + Text(details(link), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + HorizontalDivider() + } + } + } + }, + confirmButton = {}, + dismissButton = { BlackButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** For example "1080p · HLS stream · example.com" */ +private fun details(link: ExtractorLink): String = listOfNotNull( + Qualities.getStringByInt(link.quality).takeIf { it.isNotBlank() }, + if (link.type == ExtractorLinkType.M3U8) "HLS stream" else "Video file", + runCatching { URI(link.url).host?.removePrefix("www.") }.getOrNull(), +).joinToString(" · ") diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt new file mode 100644 index 00000000000..593b1735e6e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt @@ -0,0 +1,390 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import org.jetbrains.compose.resources.DrawableResource +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.download.DownloadState +import com.lagradost.cloudstream4.download.DownloadStatus +import com.lagradost.cloudstream4.download.DownloadedEpisode +import com.lagradost.cloudstream4.download.DownloadedTitle +import com.lagradost.cloudstream4.download.Downloads +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.delete_24px +import com.lagradost.cloudstream4.generated.resources.download_24px +import com.lagradost.cloudstream4.generated.resources.downloads_empty +import com.lagradost.cloudstream4.generated.resources.downloading_24px +import com.lagradost.cloudstream4.generated.resources.error +import com.lagradost.cloudstream4.generated.resources.folder_open_24px +import com.lagradost.cloudstream4.generated.resources.pause_24px +import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.library.PlaybackTracking +import com.lagradost.cloudstream4.library.TitleHeader +import com.lagradost.cloudstream4.theme.BlackButton +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import java.awt.Desktop +import java.io.File + +/** Everything downloaded or downloading, by title, with each file's progress and what can be done with it */ +@Composable +fun DownloadsScreen( + downloads: Downloads, + onPlay: (PlayRequest) -> Unit, + openDetails: (apiName: String, url: String) -> Unit, +) { + val states by downloads.states.collectAsState() + // The list changes when a download starts or is deleted, not with every progress update + val ids = states.keys + val titles = remember(ids) { downloads.titles().map { it to downloads.episodes(it.id) } } + val folder = downloads.folder + + // A readable width, centred, as on the settings pages + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Row( + Modifier.widthIn(max = DownloadsMaxWidth).fillMaxWidth().padding(start = 24.dp, top = 24.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Downloads", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold)) + val used = states.values.sumOf { it.bytes } + Text( + listOfNotNull(folder.absolutePath, used.takeIf { it > 0 }?.let { "${formatBytes(it)} used" }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + BlackButton(onClick = { openFolder(folder) }) { + Icon(painterResource(Res.drawable.folder_open_24px), contentDescription = null, modifier = Modifier.size(18.dp)) + Text("Open folder", modifier = Modifier.padding(start = 8.dp)) + } + } + if (titles.isEmpty()) { + Message(stringResource(Res.string.downloads_empty), "Use the download button on a movie or an episode to watch it offline.") + return@Column + } + LazyColumn( + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 16.dp, bottom = DockClearance), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + items(titles, key = { (title, _) -> "title-${title.id}" }) { (title, episodes) -> + fun actions(episode: DownloadedEpisode, state: DownloadState) = DownloadActions( + state = state, + onPlay = { downloadedPlayRequest(downloads, title, episode)?.let(onPlay) }, + onPause = { downloads.pause(episode.id) }, + onResume = when { + // Another source is picked on the title's page + state.pickAgain -> ({ openDetails(title.apiName, title.url) }) + downloads.canResume(title.id, episode.id) -> ({ downloads.resume(title.id, episode.id) }) + else -> null + }, + onDelete = { downloads.delete(title.id, episode.id) }, + ) + DownloadCard( + title = title, + episodes = episodes.mapNotNull { episode -> states[episode.id]?.let { episode to it } }, + label = { downloadLabel(title, it) }, + actions = ::actions, + onOpen = { openDetails(title.apiName, title.url) }, + ) + } + } + } +} + +/** How wide the list of downloads grows */ +private val DownloadsMaxWidth = 960.dp + +/** What can be done with one file, its buttons and the click on its line */ +private class DownloadActions( + val state: DownloadState, + val onPlay: () -> Unit, + val onPause: () -> Unit, + val onResume: (() -> Unit)?, + val onDelete: () -> Unit, +) + +/** + * One title as a compact card. A movie is a single line: its poster, name, status and buttons. A + * series has a short line for each episode under its name. + */ +@Composable +private fun DownloadCard( + title: DownloadedTitle, + episodes: List>, + label: (DownloadedEpisode) -> String, + actions: (DownloadedEpisode, DownloadState) -> DownloadActions, + onOpen: () -> Unit, +) { + val movie = episodes.singleOrNull()?.takeIf { it.first.episode == null } + Column( + Modifier + .widthIn(max = DownloadsMaxWidth - 48.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onOpen).padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RemoteImage(title.poster, null, contentDescription = title.name, modifier = Modifier.width(44.dp).height(66.dp), corner = 6.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + title.name, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val bytes = episodes.sumOf { it.second.bytes } + val files = if (episodes.size == 1) null else "${episodes.size} files" + Text( + listOfNotNull(title.type.name, title.apiName, files, formatBytes(bytes)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (movie != null) DownloadStatusLine(movie.second) + } + if (movie != null) DownloadButtons(actions(movie.first, movie.second)) + } + if (movie == null) episodes.forEach { (episode, state) -> + val action = actions(episode, state) + val done = state.status == DownloadStatus.Done + // Lined up with the title's name, under the poster's column + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = done, onClick = action.onPlay) + .padding(start = 68.dp, end = 10.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label(episode), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + DownloadStatusLine(state) + } + DownloadButtons(action) + } + } + if (movie == null) Spacer(Modifier.height(6.dp)) + } +} + +/** The status in a line of small text, and a thin bar under it while the file downloads or is paused */ +@Composable +private fun DownloadStatusLine(state: DownloadState) { + Text( + statusText(state), + style = MaterialTheme.typography.bodySmall, + color = if (state.status == DownloadStatus.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (state.active || state.status == DownloadStatus.Paused) { + val color = MaterialTheme.colorScheme.primary + val track = MaterialTheme.colorScheme.surface + val bar = Modifier.padding(top = 3.dp).fillMaxWidth(0.6f).height(3.dp) + val fraction = state.fraction + if (fraction != null) { + LinearProgressIndicator( + progress = { fraction }, + modifier = bar, + color = color, + trackColor = track, + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + drawStopIndicator = {}, + ) + } else if (state.active) { + LinearProgressIndicator(modifier = bar, color = color, trackColor = track, strokeCap = StrokeCap.Round, gapSize = 0.dp) + } + } +} + +/** Play, pause or resume, then delete, small enough to sit on a line of text */ +@Composable +private fun DownloadButtons(actions: DownloadActions) { + val state = actions.state + val resume = actions.onResume + Row { + when { + state.status == DownloadStatus.Done -> SmallIconButton(Res.drawable.play_arrow_24px, "Play", actions.onPlay) + state.active -> SmallIconButton(Res.drawable.pause_24px, "Pause", actions.onPause) + resume != null -> SmallIconButton(Res.drawable.downloading_24px, "Resume", resume) + } + SmallIconButton(Res.drawable.delete_24px, "Delete", actions.onDelete) + } +} + +@Composable +private fun SmallIconButton(icon: DrawableResource, description: String, onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(36.dp)) { + Icon(painterResource(icon), contentDescription = description, modifier = Modifier.size(20.dp)) + } +} + +/** + * The download button of a movie or an episode: it starts the download, then shows its progress. + * A click while it runs pauses it; once paused, failed or done, a menu offers what can be done next. + */ +@Composable +fun DownloadButton( + state: DownloadState?, + onStart: () -> Unit, + onPause: () -> Unit, + onResume: () -> Unit, + onDelete: () -> Unit, + onPlay: () -> Unit, + /** Picks another source, starting the file again */ + onPick: () -> Unit = onStart, +) { + var menu by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { + when { + state == null -> onStart() + state.active -> onPause() + else -> menu = true + } + }) { + when { + state == null -> Icon(painterResource(Res.drawable.download_24px), contentDescription = "Download") + state.active -> Box(contentAlignment = Alignment.Center) { + val fraction = state.fraction + if (fraction != null && state.status == DownloadStatus.Downloading) { + CircularProgressIndicator(progress = { fraction }, modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + } else { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + } + Icon(painterResource(Res.drawable.pause_24px), contentDescription = "Pause download", modifier = Modifier.size(14.dp)) + } + state.status == DownloadStatus.Done -> Icon( + painterResource(Res.drawable.download_24px), + contentDescription = "Downloaded", + tint = MaterialTheme.colorScheme.primary, + ) + state.status == DownloadStatus.Failed -> Icon(painterResource(Res.drawable.error), contentDescription = "Download failed", tint = MaterialTheme.colorScheme.error) + else -> Icon(painterResource(Res.drawable.downloading_24px), contentDescription = "Download paused") + } + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + state?.let { Text(statusText(it), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) } + if (state?.status == DownloadStatus.Done) DropdownMenuItem(text = { Text("Play downloaded file") }, onClick = { menu = false; onPlay() }) + if (state?.status == DownloadStatus.Paused || (state?.status == DownloadStatus.Failed && !state.pickAgain)) { + DropdownMenuItem(text = { Text(if (state.status == DownloadStatus.Failed) "Try again" else "Resume") }, onClick = { menu = false; onResume() }) + } + if (state?.status == DownloadStatus.Paused || state?.status == DownloadStatus.Failed) { + DropdownMenuItem(text = { Text("Pick another source") }, onClick = { menu = false; onPick() }) + } + DropdownMenuItem(text = { Text("Delete download") }, onClick = { menu = false; onDelete() }) + } + } +} + +/** For example "Downloading · 120 MB of 800 MB · 15%" */ +fun statusText(state: DownloadState): String { + val sizes = when { + state.total > 0 && state.status != DownloadStatus.Done -> "${formatBytes(state.bytes)} of ${formatBytes(state.total)}" + state.bytes > 0 -> formatBytes(state.bytes) + else -> null + } + val percent = state.fraction?.takeIf { state.status != DownloadStatus.Done }?.let { "${(it * 100).toInt()}%" } + return when (state.status) { + DownloadStatus.Queued -> "Waiting for another download to finish" + DownloadStatus.FindingLinks -> "Finding links…" + DownloadStatus.Downloading -> listOfNotNull("Downloading", sizes, percent).joinToString(" · ") + DownloadStatus.Paused -> listOfNotNull("Paused", sizes, percent).joinToString(" · ") + DownloadStatus.Failed -> "Failed: ${state.message ?: "unknown error"}" + DownloadStatus.Done -> listOfNotNull("Downloaded", sizes).joinToString(" · ") + } +} + +fun formatBytes(bytes: Long): String = when { + bytes >= 1L shl 30 -> "%.1f GB".format(bytes / (1L shl 30).toDouble()) + bytes >= 1L shl 20 -> "%d MB".format(bytes / (1L shl 20)) + else -> "%d KB".format(bytes / 1024) +} + +/** "Movie", or "S1 E2 · Pilot" */ +private fun downloadLabel(title: DownloadedTitle, episode: DownloadedEpisode): String { + if (episode.episode == null) return title.name + val number = listOfNotNull(episode.season?.let { "S$it" }, "E${episode.episode}").joinToString(" ") + return listOf(number, episode.name.orEmpty().trim()).filter { it.isNotEmpty() }.joinToString(" · ") +} + +/** + * Plays a download from the Downloads page, where the title's page may not open (offline, or the + * extension gone). Its progress is saved as if it played from the title's page. + */ +fun downloadedPlayRequest(downloads: Downloads, title: DownloadedTitle, episode: DownloadedEpisode): PlayRequest? { + val file = downloads.file(episode.id) ?: return null + val header = TitleHeader(title.id, title.apiName, title.url, title.name, title.type, title.poster) + val done = downloads.episodes(title.id).filter { downloads.file(it.id) != null } + val next = done.getOrNull(done.indexOfFirst { it.id == episode.id } + 1)?.takeIf { episode.episode != null } + val position = DataStoreWatchStore.instance.position(episode.id)?.takeUnless { it.watched }?.positionMs ?: 0 + val tracking = PlaybackTracking( + header = header, + id = episode.id, + episode = episode.episode, + season = episode.season, + next = next?.let { PlaybackTracking.NextEpisode(it.id, it.episode, it.season) }, + startPositionMs = position, + ) + val label = if (episode.episode == null) null else downloadLabel(title, episode) + return PlayRequest(title.apiName, title.name, label, "", tracking, file.absolutePath, localSubtitles(downloads, episode.id)) +} + +/** The subtitles saved beside a download, for the player */ +fun localSubtitles(downloads: Downloads, id: Int): List = + downloads.subtitles(id).map { (language, file) -> SubtitleFile(language, file.absolutePath) } + +private fun openFolder(folder: File) { + runCatching { + folder.mkdirs() + Desktop.getDesktop().open(folder) + }.onFailure { println("WARN DownloadsScreen: could not open $folder: $it") } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Glass.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Glass.kt new file mode 100644 index 00000000000..1163815386e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Glass.kt @@ -0,0 +1,113 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect + +/** + * Frosted glass in the theme's colours, floating over the page: what is behind it shows blurred + * through, as on the dock and the Home page's bar. [state] is what it blurs, marked with hazeSource. + */ +@Composable +fun Modifier.glass(state: HazeState, shape: Shape): Modifier { + val background = MaterialTheme.colorScheme.background + val light = background.luminance() > 0.5f + // A light edge along the top, where light would catch the glass; on a light theme a faint dark + // one, as a white edge would not show + val edge = if (light) { + listOf(Color.Black.copy(alpha = 0.10f), Color.Black.copy(alpha = 0.05f)) + } else { + listOf(Color.White.copy(alpha = 0.22f), Color.White.copy(alpha = 0.04f)) + } + val shadow = Color.Black.copy(alpha = if (light) 0.35f else 1f) + return this + .shadow(if (light) 16.dp else 24.dp, shape, ambientColor = shadow, spotColor = shadow) + .clip(shape) + .hazeEffect( + state, + HazeStyle( + backgroundColor = background, + tint = HazeTint(background.copy(alpha = if (light) 0.65f else 0.55f)), + blurRadius = 28.dp, + noiseFactor = 0.06f, + ), + ) + .border(1.dp, Brush.verticalGradient(edge), shape) +} + +/** + * A button on glass: a pill, or a circle when [round], lit under the mouse and while [active], such + * as while the menu it opens is open + */ +@Composable +fun GlassButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + active: Boolean = false, + round: Boolean = false, + content: @Composable RowScope.() -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val onBackground = MaterialTheme.colorScheme.onBackground + Row( + modifier + .height(44.dp) + .then(if (round) Modifier.width(44.dp) else Modifier) + .clip(RoundedCornerShape(percent = 50)) + .background( + when { + active -> onBackground.copy(alpha = 0.12f) + hovered && enabled -> onBackground.copy(alpha = 0.08f) + else -> Color.Transparent + }, + ) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onClick) + .then(if (round) Modifier else Modifier.padding(horizontal = 16.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + CompositionLocalProvider(LocalContentColor provides onBackground.copy(alpha = if (enabled) 1f else 0.6f)) { content() } + } +} + +/** The name of a button on glass, under the mouse */ +@Composable +fun GlassTooltip(text: String) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 4.dp) { + Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt new file mode 100644 index 00000000000..05566bf4b1d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -0,0 +1,360 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.background +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.DpOffset +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import coil3.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.generated.resources.info +import com.lagradost.cloudstream4.providers.loadSafely +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.autorenew_24px +import com.lagradost.cloudstream4.generated.resources.check +import com.lagradost.cloudstream4.generated.resources.chevron_right +import com.lagradost.cloudstream4.generated.resources.search_icon +import com.lagradost.cloudstream4.home.HomeAction +import com.lagradost.cloudstream4.home.HomeState +import com.lagradost.cloudstream4.home.HomeStatus +import com.lagradost.cloudstream4.home.HomeViewModel +import com.lagradost.cloudstream4.theme.WhiteButton +import org.jetbrains.compose.resources.painterResource + +/** The home page of one provider, picked from a menu at the top */ +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + openExtensions: () -> Unit, + openDetails: (apiName: String, url: String) -> Unit, + /** Plays a Continue watching title where it was left */ + resume: (apiName: String, url: String) -> Unit, +) { + val state by viewModel.state.collectAsState() + // What the bar at the top blurs behind its glass + val glass = rememberHazeState() + // The provider's first title, large, as streaming apps open on one + val featured = (state.status as? HomeStatus.Done)?.let { state.rows.firstOrNull()?.items?.firstOrNull() } + val showBar = state.providers.isNotEmpty() + + Box(Modifier.fillMaxSize()) { + // One list, so Continue watching scrolls away with the rows below it. The bar floats over the + // banner; without one, the list starts below the bar + LazyColumn( + contentPadding = PaddingValues(top = if (showBar && featured == null) HomeBarClearance else 0.dp, bottom = DockClearance), + modifier = Modifier.fillMaxSize().hazeSource(glass), + ) { + if (featured != null) item(key = "featured") { FeaturedBanner(featured, openDetails) } + // Above the provider's rows, so it is there even when the provider fails to load + item { ContinueWatchingRow(DataStoreWatchStore.instance, resume, Modifier.padding(top = 8.dp)) } + + // What fills the rest of the page while there are no rows + fun LazyListScope.whole(content: @Composable () -> Unit) = item { + Box(Modifier.fillParentMaxWidth().fillParentMaxHeight(0.8f), contentAlignment = Alignment.Center) { content() } + } + when (val status = state.status) { + HomeStatus.NoProviders -> whole { + Message( + "No extensions with a home page", + "Install an extension to browse it here. If you already have, check the extension languages and media types under Settings > Providers.", + ) { WhiteButton(onClick = openExtensions) { Text("Open Extensions") } } + } + + HomeStatus.Loading -> whole { CircularProgressIndicator() } + + is HomeStatus.Failed -> whole { + Message("${state.selected} could not be loaded", status.message) { + WhiteButton(onClick = { viewModel.onAction(HomeAction.Reload) }) { Text("Try again") } + } + } + + HomeStatus.Done -> if (state.rows.isEmpty()) { + whole { Message("${state.selected} returned an empty home page") } + } else { + items(state.rows) { row -> + PosterRow( + title = row.name, + items = row.items, + horizontal = row.horizontalImages, + modifier = Modifier.padding(top = 8.dp), + onItemClick = { openDetails(it.apiName, it.url) }, + ) + } + } + } + } + if (showBar) HomeBar(state, viewModel::onAction, glass, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) + } +} + +/** The room the bar takes at the top of the page, with the space around it */ +private val HomeBarClearance = 84.dp + +/** + * A pill of frosted glass floating at the top, as the dock does at the bottom: the provider, which + * opens the list of the others, and Reload, which turns while the page loads. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HomeBar(state: HomeState, onAction: (HomeAction) -> Unit, glass: HazeState, modifier: Modifier = Modifier) { + var menuOpen by remember { mutableStateOf(false) } + val loading = state.status == HomeStatus.Loading + Row( + modifier.glass(glass, RoundedCornerShape(percent = 50)).padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box { + GlassButton(onClick = { menuOpen = true }, active = menuOpen, modifier = Modifier.widthIn(max = 360.dp)) { + Text( + state.selected ?: "", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Icon( + painterResource(Res.drawable.chevron_right), + contentDescription = "Choose provider", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 6.dp).size(20.dp).rotate(if (menuOpen) 270f else 90f), + ) + } + ProviderMenu( + expanded = menuOpen, + providers = state.providers, + selected = state.selected, + onDismiss = { menuOpen = false }, + onSelect = { + menuOpen = false + onAction(HomeAction.SelectProvider(it)) + }, + ) + } + // A hairline between the two + Box(Modifier.size(1.dp, 22.dp).background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.14f))) + TooltipArea( + tooltip = { GlassTooltip("Reload") }, + delayMillis = 300, + tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.BottomCenter, alignment = Alignment.BottomCenter, offset = DpOffset(0.dp, 8.dp)), + ) { + val turn = rememberInfiniteTransition() + val angle by turn.animateFloat(0f, 360f, infiniteRepeatable(tween(900, easing = LinearEasing))) + GlassButton(onClick = { onAction(HomeAction.Reload) }, enabled = !loading, round = true) { + Icon( + painterResource(Res.drawable.autorenew_24px), + contentDescription = "Reload", + modifier = Modifier.size(20.dp).rotate(if (loading) angle else 0f), + ) + } + } + } +} + +/** + * The providers to pick from, in a rounded card of the theme's colours, the current one ticked. With + * many, a box at the top narrows them down as you type. + */ +@Composable +private fun ProviderMenu( + expanded: Boolean, + providers: List, + selected: String?, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { + var query by remember(expanded) { mutableStateOf("") } + val colors = MaterialTheme.colorScheme + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + offset = DpOffset(0.dp, 10.dp), + shape = RoundedCornerShape(16.dp), + containerColor = colors.surfaceContainer, + tonalElevation = 0.dp, + shadowElevation = 16.dp, + border = BorderStroke(1.dp, colors.onBackground.copy(alpha = 0.08f)), + modifier = Modifier.widthIn(min = 260.dp).heightIn(max = 480.dp), + ) { + if (providers.size > 8) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + BasicTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = colors.onBackground), + cursorBrush = SolidColor(colors.primary), + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 6.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.surface) + .focusRequester(focus), + decorationBox = { field -> + Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(Res.drawable.search_icon), contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Box(Modifier.padding(start = 10.dp)) { + if (query.isEmpty()) Text("Find a provider", style = MaterialTheme.typography.bodyMedium, color = colors.onSurfaceVariant) + field() + } + } + }, + ) + } + val shown = providers.filter { it.contains(query.trim(), ignoreCase = true) } + if (shown.isEmpty()) Text( + "No provider called \"${query.trim()}\"", + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + shown.forEach { name -> + val current = name == selected + DropdownMenuItem( + text = { + Text( + name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal), + color = if (current) colors.primary else colors.onBackground, + ) + }, + trailingIcon = if (current) { + { Icon(painterResource(Res.drawable.check), contentDescription = "Current", tint = colors.primary, modifier = Modifier.size(18.dp)) } + } else null, + onClick = { onSelect(name) }, + modifier = Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)), + ) + } + } +} + +/** + * The provider's first title across the top of the page: its backdrop, faded into the page, with + * its name, a few facts and the start of its story. The rows only have its poster, so the rest + * loads from its page; the poster stands in until then. What the extension gives for it is used as + * the Android app's home preview does: the wide background picture, and the title's logo in place + * of its name, which comes back if the logo does not load. + */ +@Composable +private fun LazyItemScope.FeaturedBanner(item: SearchResponse, openDetails: (apiName: String, url: String) -> Unit) { + val details by produceState(null, item.apiName, item.url) { + val api = APIHolder.getApiFromNameNull(item.apiName) ?: return@produceState + value = (api.loadSafely(item.url) as? Resource.Success)?.value + } + val background = MaterialTheme.colorScheme.background + Box( + Modifier + .fillParentMaxWidth() + .heightIn(min = 340.dp, max = 600.dp) + .fillParentMaxHeight(0.62f) + .padding(bottom = 8.dp), + ) { + RemoteImage( + details?.backgroundPosterUrl ?: details?.posterUrl ?: item.posterUrl, + details?.posterHeaders ?: item.posterHeaders, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + corner = 0.dp, + ) + // Faded into the page below and to the left, where the text is + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0.45f to Color.Transparent, 1f to background))) + Box(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to background.copy(alpha = 0.92f), 0.55f to Color.Transparent))) + Column( + Modifier.align(Alignment.BottomStart).fillMaxWidth(0.5f).padding(start = PageGutter, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + val name = details?.name ?: item.name + var logoFailed by remember(details?.logoUrl) { mutableStateOf(false) } + val logo = details?.logoUrl?.takeIf { it.isNotBlank() && !logoFailed } + if (logo != null) AsyncImage( + model = logo, + contentDescription = name, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + onError = { logoFailed = true }, + modifier = Modifier.fillMaxWidth(0.8f).height(120.dp), + ) else Text( + name, + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val facts = listOfNotNull( + details?.score?.let { "★ ${"%.1f".format(it.toFloat(10))}" }, + details?.contentRating?.takeIf { it.isNotBlank() }, + details?.year?.toString(), + details?.duration?.let { "$it min" }, + details?.tags?.take(3)?.joinToString(", ")?.ifEmpty { null }, + ) + if (facts.isNotEmpty()) Text(facts.joinToString(" · "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + details?.plot?.let { Text(it, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis) } + Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WhiteButton(onClick = { openDetails(item.apiName, item.url) }) { + Icon(painterResource(Res.drawable.info), contentDescription = null) + Text("More info", modifier = Modifier.padding(start = 8.dp)) + } + } + } + } +} + diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt new file mode 100644 index 00000000000..c97e9c56b7b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt @@ -0,0 +1,168 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.close +import com.lagradost.cloudstream4.library.PlaybackPosition +import com.lagradost.cloudstream4.library.ResumeEntry +import com.lagradost.cloudstream4.library.TitleHeader +import com.lagradost.cloudstream4.library.WatchStore +import com.lagradost.cloudstream4.library.WatchType +import com.lagradost.cloudstream4.theme.AppFilterChip +import org.jetbrains.compose.resources.painterResource + +/** The titles the user put in a library list, one list at a time, most recently changed first */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LibraryScreen(store: WatchStore, openDetails: (apiName: String, url: String) -> Unit) { + val version by store.version.collectAsState() + val bookmarks = remember(version) { store.bookmarks().sortedByDescending { it.latestUpdatedTime } } + var selected by rememberSaveable { mutableStateOf(WatchType.WATCHING) } + val shown = bookmarks.filter { it.status == selected } + + Column(Modifier.fillMaxSize()) { + Text("Library", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(start = 24.dp, top = 24.dp, end = 24.dp)) + // Product Sans draws "(5)" as a circled digit, so counts use a separator instead of parentheses + FlowRow(Modifier.padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WatchType.lists.forEach { type -> + val count = bookmarks.count { it.status == type } + AppFilterChip( + selected = type == selected, + onClick = { selected = type }, + label = { Text(if (count > 0) "${type.label} · $count" else type.label) }, + ) + } + } + if (shown.isEmpty()) { + Message( + "Nothing in ${selected.label} yet", + "Open a title and use Add to library to keep it here.", + ) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(150.dp), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = DockClearance), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(shown, key = { it.header.id }) { bookmark -> + TitleCard(bookmark.header, onClick = { openDetails(bookmark.header.apiName, bookmark.header.url) }) + } + } + } + } +} + +@Composable +private fun TitleCard(header: TitleHeader, modifier: Modifier = Modifier, onClick: () -> Unit) { + Column(modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick)) { + RemoteImage(header.posterUrl, header.posterHeaders, contentDescription = header.name, modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)) + Text(header.name, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 6.dp)) + Text( + listOfNotNull(header.year?.toString(), header.apiName).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The titles the user started, newest first, like the Android app's Continue watching row. Each is + * a small card: the poster, the title, how far its episode got, and a button to drop it from the + * row. Clicking one plays it where it was left, from the source it was last watched on. + */ +@Composable +fun ContinueWatchingRow(store: WatchStore, resume: (apiName: String, url: String) -> Unit, modifier: Modifier = Modifier) { + val version by store.version.collectAsState() + val entries = remember(version) { store.resumeEntries().sortedByDescending { it.updateTime } } + if (entries.isEmpty()) return + Column(modifier.fillMaxWidth()) { + RowTitle("Continue watching", Modifier.padding(horizontal = PageGutter, vertical = 4.dp)) + ScrollingRow { + items(entries, key = { it.header.id }) { entry -> + val position = remember(version, entry) { store.position(entry.episodeId ?: entry.header.id) } + ContinueCard( + entry = entry, + position = position, + onClick = { resume(entry.header.apiName, entry.header.url) }, + onRemove = { store.removeResume(entry.header.id) }, + ) + } + } + } +} + +@Composable +private fun ContinueCard(entry: ResumeEntry, position: PlaybackPosition?, onClick: () -> Unit, onRemove: () -> Unit) { + val header = entry.header + val interaction = remember { MutableInteractionSource() } + Row( + Modifier + .width(280.dp) + .hoverZoom(interaction) + .height(84.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), + ) { + RemoteImage(header.posterUrl, header.posterHeaders, contentDescription = header.name, modifier = Modifier.width(56.dp).fillMaxHeight()) + Column(Modifier.weight(1f).fillMaxHeight().padding(start = 10.dp, top = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.SpaceBetween) { + Text(header.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + val episode = listOfNotNull(entry.season?.let { "S$it" }, entry.episode?.let { "E$it" }).joinToString(" ") + val left = position?.takeIf { !it.watched && it.durationMs > 0 }?.let { "${(it.durationMs - it.positionMs) / 60_000} min left" } + Text( + listOfNotNull(episode.ifEmpty { null }, left).joinToString(" · ").ifEmpty { header.apiName }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + LinearProgressIndicator( + progress = { if (position == null || position.watched) 0f else position.fraction }, + modifier = Modifier.fillMaxWidth().height(3.dp), + ) + } + IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Icon(painterResource(Res.drawable.close), contentDescription = "Remove from Continue watching", modifier = Modifier.size(16.dp)) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt new file mode 100644 index 00000000000..c97fd25d45c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -0,0 +1,314 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.background +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.zIndex +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.chevron_left +import com.lagradost.cloudstream4.generated.resources.chevron_right +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.lagradost.cloudstream4.network.DesktopHttp +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.serviceLoaderEnabled +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.theme.BlackButton +import okio.Path.Companion.toOkioPath + +/** + * Loads posters with OkHttp like the Android app does. OkHttp tries every address a host has, + * which the JDK client used by Coil's default network module does not. + */ +fun desktopImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + .crossfade(200) + // A fixed size: a share of the heap limit would grow with it, and decoded posters live outside the + // heap. 96 MB holds a few hundred posters at the size they are shown + .memoryCache { MemoryCache.Builder().maxSizeBytes(96L * 1024 * 1024).build() } + .diskCache { + DiskCache.Builder() + .directory(AppDirs.cache.resolve("images").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) + .build() + } + // Only this fetcher, so the Ktor one that the shared module brings in is never picked instead + .serviceLoaderEnabled(false) + // The app's connections and DNS, without its HTTP cache: posters have the disk cache above + .components { add(OkHttpNetworkFetcherFactory(callFactory = { DesktopHttp.uncached })) } + .build() + +private val verticalWidth = 140.dp +private val horizontalWidth = 240.dp + +/** An image from a provider, which may need its own headers to serve it */ +@Composable +fun RemoteImage(url: String?, headers: Map?, contentDescription: String?, modifier: Modifier = Modifier, corner: Dp = 8.dp) { + val context = LocalPlatformContext.current + val request = remember(url, headers) { + ImageRequest.Builder(context) + .data(url) + .httpHeaders(NetworkHeaders.Builder().apply { + set("User-Agent", USER_AGENT) + headers?.forEach { (key, value) -> set(key, value) } + }.build()) + .build() + } + Box(modifier.clip(RoundedCornerShape(corner)).background(MaterialTheme.colorScheme.surfaceVariant)) { + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } +} + +/** The space at the sides of the pages' rows */ +val PageGutter = 32.dp + +/** Room left under a page's last row, so the floating dock at the bottom does not cover it */ +val DockClearance = 104.dp + +/** + * Grows a little under the mouse, as cards do in streaming apps, over its neighbours. With [shadow] + * the whole card casts one; a poster with its title under it casts it from the picture alone, see + * [PosterCard], or the text would sit in a grey box on a light theme. + */ +@Composable +fun Modifier.hoverZoom(interaction: MutableInteractionSource, enabled: Boolean = true, shadow: Boolean = true): Modifier { + val hovered by interaction.collectIsHoveredAsState() + val scale by animateFloatAsState(if (hovered && enabled) 1.06f else 1f, tween(150)) + return zIndex(if (hovered) 1f else 0f) + .graphicsLayer { + scaleX = scale + scaleY = scale + if (shadow) { + shadowElevation = (scale - 1f) * 200f + shape = RoundedCornerShape(8.dp) + } + } + .hoverable(interaction) +} + +/** A poster with its title underneath */ +@Composable +fun PosterCard(item: SearchResponse, horizontal: Boolean, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val lift by animateDpAsState(if (hovered && onClick != null) 14.dp else 0.dp, tween(150)) + Column( + modifier + .width(if (horizontal) horizontalWidth else verticalWidth) + .hoverZoom(interaction, enabled = onClick != null, shadow = false) + .then(if (onClick != null) Modifier.clickable(interactionSource = null, indication = null, onClick = onClick) else Modifier) + ) { + RemoteImage( + item.posterUrl, + item.posterHeaders, + contentDescription = item.name, + modifier = Modifier.fillMaxWidth().aspectRatio(if (horizontal) 16f / 9f else 2f / 3f).shadow(lift, RoundedCornerShape(8.dp)), + ) + Text( + item.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + } +} + +/** A row's title, the same on every page */ +@Composable +fun RowTitle(title: String, modifier: Modifier = Modifier) { + Text(title, style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), modifier = modifier) +} + +/** + * A titled, sideways-scrolling row of posters. Under the mouse, arrows at its ends scroll it by + * most of its width, as the mouse cannot swipe. + * + * @param trailing shown after the last poster, for example a "More" button + */ +@Composable +fun PosterRow( + title: String, + items: List, + horizontal: Boolean, + modifier: Modifier = Modifier, + subtitle: String? = null, + loading: Boolean = false, + onItemClick: ((SearchResponse) -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + Column(modifier.fillMaxWidth()) { + Row( + Modifier.padding(horizontal = PageGutter, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RowTitle(title) + subtitle?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (loading) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } + ScrollingRow { + // No keys, providers sometimes list the same item twice and duplicate keys crash the row + items(items) { item -> PosterCard(item, horizontal, onClick = onItemClick?.let { { it(item) } }) } + if (trailing != null) item { trailing() } + } + } +} + +/** A sideways row with the arrows of [PosterRow], room around its cards to grow under the mouse */ +@Composable +fun ScrollingRow(content: LazyListScope.() -> Unit) { + val state = rememberLazyListState() + val scope = rememberCoroutineScope() + val hover = remember { MutableInteractionSource() } + val hovered by hover.collectIsHoveredAsState() + var width by remember { mutableStateOf(0) } + Box(Modifier.fillMaxWidth().hoverable(hover).onSizeChanged { width = it.width }) { + LazyRow( + state = state, + contentPadding = PaddingValues(horizontal = PageGutter, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) + Row(Modifier.matchParentSize()) { + if (hovered && state.canScrollBackward) RowArrow(Res.drawable.chevron_left, "Scroll back", start = true) { + scope.launch { state.animateScrollBy(-width * 0.8f) } + } + Spacer(Modifier.weight(1f)) + if (hovered && state.canScrollForward) RowArrow(Res.drawable.chevron_right, "Scroll on", start = false) { + scope.launch { state.animateScrollBy(width * 0.8f) } + } + } + } +} + +/** + * A round button in the theme's colours at an end of a row, over the posters faded into the page, + * so it shows on any picture and on light themes alike + */ +@Composable +private fun RowArrow(icon: DrawableResource, description: String, start: Boolean, onClick: () -> Unit) { + val background = MaterialTheme.colorScheme.background + val fade = listOf(background.copy(alpha = 0.85f), Color.Transparent) + Box( + Modifier + .width(72.dp) + .fillMaxHeight() + .background(Brush.horizontalGradient(if (start) fade else fade.reversed())), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(44.dp) + .shadow(10.dp, CircleShape) + .background(MaterialTheme.colorScheme.surface, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(painterResource(icon), contentDescription = description, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(28.dp)) + } + } +} + +/** The end of a row that has more pages */ +@Composable +fun MoreButton(loading: Boolean, horizontal: Boolean, onClick: () -> Unit) { + Box( + Modifier.width(if (horizontal) horizontalWidth else verticalWidth).aspectRatio(if (horizontal) 16f / 9f else 2f / 3f), + contentAlignment = Alignment.Center, + ) { + if (loading) CircularProgressIndicator(Modifier.size(24.dp)) + else BlackButton(onClick = onClick) { Text("More") } + } +} + +/** A centred message for empty, loading and error states */ +@Composable +fun Message(title: String, body: String? = null, action: (@Composable () -> Unit)? = null) { + Column( + Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title, style = MaterialTheme.typography.titleLarge) + body?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + action?.let { + Spacer(Modifier.size(16.dp)) + it() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt new file mode 100644 index 00000000000..8b337735fed --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -0,0 +1,188 @@ +package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.ui.graphics.Color + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.close +import com.lagradost.cloudstream4.generated.resources.history_toggle_off_24px +import com.lagradost.cloudstream4.generated.resources.search_icon +import com.lagradost.cloudstream4.generated.resources.title_search +import com.lagradost.cloudstream4.search.ProviderStatus +import com.lagradost.cloudstream4.search.SearchAction +import com.lagradost.cloudstream4.search.SearchHistoryEntry +import com.lagradost.cloudstream4.search.SearchViewModel +import com.lagradost.cloudstream4.theme.WhiteButton +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** + * Searches every enabled provider. Results are grouped by provider, the same as the Android app's + * advanced search, and each provider's row appears as soon as it answers. + */ +@Composable +fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDetails: (apiName: String, url: String) -> Unit) { + val state by viewModel.state.collectAsState() + val focus = remember { FocusRequester() } + // Opening Search puts the cursor in the box, ready to type + LaunchedEffect(Unit) { focus.requestFocus() } + + Column(Modifier.fillMaxSize()) { + OutlinedTextField( + value = state.query, + onValueChange = { viewModel.onAction(SearchAction.QueryChanged(it)) }, + placeholder = { Text(stringResource(Res.string.title_search)) }, + leadingIcon = { Icon(painterResource(Res.drawable.search_icon), contentDescription = null) }, + trailingIcon = { + if (state.query.isNotEmpty()) { + IconButton(onClick = { viewModel.onAction(SearchAction.Clear) }) { + Icon(painterResource(Res.drawable.close), contentDescription = "Clear") + } + } + }, + singleLine = true, + // A rounded box filled with the theme's box colour, outlined in the accent only while typing + shape = RoundedCornerShape(percent = 50), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PageGutter, vertical = 16.dp) + .focusRequester(focus) + .onPreviewKeyEvent { + // Enter searches; handled here because desktop keyboards have no IME search action + if (it.type == KeyEventType.KeyDown && (it.key == Key.Enter || it.key == Key.NumPadEnter)) { + viewModel.onAction(SearchAction.Submit) + true + } else false + }, + ) + if (state.isSearching) LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = PageGutter)) + + val found = state.results.filter { it.items.isNotEmpty() } + val failed = state.results.filter { it.status is ProviderStatus.Failed && it.items.isEmpty() } + when { + state.noProviders -> Message( + "No extensions to search with", + "Install an extension, or check the extension languages and media types under Settings > Providers.", + ) { WhiteButton(onClick = openExtensions) { Text("Open Extensions") } } + + state.searchedQuery.isEmpty() && state.history.isNotEmpty() -> SearchHistory( + entries = state.history, + onSearch = { viewModel.onAction(SearchAction.SearchFor(it.query)) }, + onRemove = { viewModel.onAction(SearchAction.RemoveHistory(it.key)) }, + onClear = { viewModel.onAction(SearchAction.ClearHistory) }, + ) + + state.searchedQuery.isEmpty() -> Message("Search all your extensions", "Type a title and press Enter.") + + found.isEmpty() && !state.isSearching -> Message( + "Nothing found for \"${state.searchedQuery}\"", + if (failed.isEmpty()) null else "${failed.size} of ${state.results.size} extensions could not be reached.", + ) + + else -> LazyColumn( + contentPadding = PaddingValues(bottom = DockClearance), + modifier = Modifier.fillMaxSize(), + ) { + items(found) { row -> + val loading = row.status == ProviderStatus.Loading + PosterRow( + title = row.apiName, + subtitle = "${row.items.size} results", + items = row.items, + horizontal = false, + modifier = Modifier.padding(top = 8.dp), + onItemClick = { openDetails(it.apiName, it.url) }, + trailing = if (row.hasNext) { + { MoreButton(loading, horizontal = false) { viewModel.onAction(SearchAction.LoadMore(row.apiName)) } } + } else null, + ) + } + if (failed.isNotEmpty()) item { + Text( + "Could not reach: " + failed.joinToString { it.apiName }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + ) + } + } + } + } +} + +/** Past searches, newest first, like the Android app's search history */ +@Composable +private fun SearchHistory( + entries: List, + onSearch: (SearchHistoryEntry) -> Unit, + onRemove: (SearchHistoryEntry) -> Unit, + onClear: () -> Unit, +) { + LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { + item { + Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Recent searches", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + TextButton(onClick = onClear) { Text("Clear all") } + } + } + items(entries, key = { it.key }) { entry -> + Row( + Modifier.fillMaxWidth().clickable { onSearch(entry) }.padding(start = 24.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(Res.drawable.history_toggle_off_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + entry.query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + ) + IconButton(onClick = { onRemove(entry) }) { + Icon(painterResource(Res.drawable.close), contentDescription = "Remove from history") + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt new file mode 100644 index 00000000000..8561f3ee95f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -0,0 +1,676 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.win32.StdCallLibrary +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import java.awt.Canvas +import java.awt.Color +import java.awt.Rectangle +import java.awt.Toolkit +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseWheelEvent +import java.io.File +import javax.swing.JComponent +import javax.swing.JLayeredPane +import javax.swing.SwingUtilities +import java.util.concurrent.TimeUnit +import java.util.concurrent.CountDownLatch +import kotlin.math.log2 + +/** The libmpv functions the player uses, see mpv/client.h */ +@Suppress("FunctionName") +internal interface MpvLibrary : Library { + fun mpv_create(): Pointer? + fun mpv_initialize(ctx: Pointer): Int + fun mpv_set_option_string(ctx: Pointer, name: String, value: String): Int + fun mpv_get_property_string(ctx: Pointer, name: String): Pointer? + fun mpv_command(ctx: Pointer, args: Array): Int + fun mpv_free(data: Pointer) + fun mpv_error_string(error: Int): String + fun mpv_wait_event(ctx: Pointer, timeout: Double): Pointer + fun mpv_request_log_messages(ctx: Pointer, minLevel: String): Int + fun mpv_terminate_destroy(ctx: Pointer) +} + +/** The Windows functions that show only part of the video, see MpvPlayer.place */ +@Suppress("FunctionName") +private class Win32(val user: User, val gdi: Gdi) { + interface User : StdCallLibrary { + fun SetWindowRgn(hwnd: Pointer, region: Pointer?, redraw: Boolean): Int + } + + interface Gdi : StdCallLibrary { + fun CreateRectRgn(left: Int, top: Int, right: Int, bottom: Int): Pointer? + } + + companion object { + val instance: Win32? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Win32(Native.load("user32", User::class.java), Native.load("gdi32", Gdi::class.java)) } + .onFailure { println("ERROR Mpv: user32 or gdi32 could not be loaded: $it") } + .getOrNull() + } + } +} + +/** Finds and loads libmpv-2.dll once */ +object Mpv { + private const val FILE = "libmpv-2.dll" + + /** Why libmpv could not be loaded, or null when it loaded */ + var loadError: String? = null + private set + + internal val library: MpvLibrary? by lazy { + // The installed app keeps it in its resources folder. When run from Gradle it is in the build folder + val candidates = listOfNotNull( + System.getProperty("compose.application.resources.dir")?.let { File(it, FILE) }, + File("build/libmpv/windows", FILE), + File("desktopApp/build/libmpv/windows", FILE), + ) + val file = candidates.firstOrNull { it.isFile } + if (file == null) { + loadError = "$FILE was not found. Looked in: ${candidates.joinToString { it.absolutePath }}" + println("ERROR Mpv: $loadError") + return@lazy null + } + runCatching { + Native.load(file.absolutePath, MpvLibrary::class.java, mapOf(Library.OPTION_STRING_ENCODING to "UTF-8")) + }.onSuccess { + println("INFO Mpv: Loaded ${file.absolutePath}") + }.onFailure { + loadError = "$FILE could not be loaded: ${it.message}" + println("ERROR Mpv: $loadError") + }.getOrNull() + } +} + +data class MpvTrack( + val id: Int, + /** "video", "audio" or "sub" */ + val type: String, + val title: String?, + val lang: String?, + val selected: Boolean, + val external: Boolean, + /** The file an external track was added from */ + val externalFilename: String? = null, + /** For example "eac3" or "subrip" */ + val codec: String? = null, + /** Audio channels, for example 6 for 5.1 */ + val channels: Int? = null, + val default: Boolean = false, +) { + /** The language spelled out, for example "Hindi" for "hin" */ + val language: String? get() = lang?.let(::languageName) + + /** For example "Hindi", "Commentary · English" or "Track 2" */ + val label: String get() = listOfNotNull(title, language).filter { it.isNotBlank() }.distinctBy { it.lowercase() } + .joinToString(" · ").ifEmpty { "Track $id" } + + /** For example "5.1 · EAC3", what tells two tracks in one language apart */ + val details: String get() = listOfNotNull( + channels?.let { when (it) { 1 -> "Mono"; 2 -> "Stereo"; 6 -> "5.1"; 8 -> "7.1"; else -> "$it channels" } }, + codec?.let { codecNames[it] ?: it.uppercase() }, + "Default".takeIf { default }, + "Picture, keeps its own look".takeIf { isPicture }, + ).joinToString(" · ") + + /** A subtitle drawn as pictures, as on Blu-rays and DVDs: the subtitle settings cannot change how it looks */ + val isPicture: Boolean get() = type == "sub" && codec in pictureSubtitles + + private companion object { + val pictureSubtitles = setOf("hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle", "xsub") + val codecNames = mapOf( + "hdmv_pgs_subtitle" to "PGS", "dvd_subtitle" to "VobSub", "dvb_subtitle" to "DVB", + "subrip" to "SRT", "webvtt" to "WebVTT", "mov_text" to "Text", + ) + } +} + +/** "Hindi" for "hin" or "hi", the code itself when it is not a language */ +internal fun languageName(code: String): String { + val locale = java.util.Locale.forLanguageTag(code) + val name = locale.getDisplayLanguage(java.util.Locale.ENGLISH) + return if (name.isBlank() || name.equals(code, ignoreCase = true)) code else name +} + +data class MpvStatus( + val position: Double = 0.0, + val duration: Double = 0.0, + val paused: Boolean = false, + /** Waiting for data: the file is opening, a seek is under way or the cache ran empty */ + val buffering: Boolean = false, + /** How full the cache is before playback goes on, 0 to 100, when mpv knows */ + val bufferPercent: Int? = null, + /** How far ahead of the start the cache reaches, in seconds */ + val bufferedTo: Double = 0.0, + val tracks: List = emptyList(), + /** 0 to 100 */ + val volume: Int = 100, + val muted: Boolean = false, + val speed: Double = 1.0, + /** Seconds the subtitles are shown later, negative for earlier */ + val subDelay: Double = 0.0, +) + +/** A subtitle file to show with a link, added again whenever the link changes */ +data class MpvSubtitle(val file: File, val title: String, val select: Boolean) + +/** + * How the picture fits the video's area. A 16:9 video on a 16:9 screen looks the same fitted, filled + * or stretched: the zooms crop into it, also past black bars that are part of the video itself + */ +enum class AspectMode(val label: String, val zoom: Double = 1.0) { + Fit("Fit"), + Fill("Crop to Fill"), + Zoom110("Zoom 110%", 1.10), + Zoom125("Zoom 125%", 1.25), + Zoom150("Zoom 150%", 1.50), + SixteenNine("16:9"), + FourThree("4:3"), + Stretch("Stretch"), +} + +sealed interface MpvEvent { + /** F or a double click inside the video */ + data object ToggleFullscreen : MpvEvent + /** A single click inside the video */ + data object Click : MpvEvent + /** The mouse's back button inside the video */ + data object Back : MpvEvent + data object Ended : MpvEvent + /** + * The mouse wheel or a touchpad turned over the video, at [x] across it from 0 to 1, by [notches] + * of a wheel, up positive + */ + data class Wheel(val x: Float, val notches: Double) : MpvEvent + /** The file could not be played, for example a refused or broken link */ + data class Failed(val url: String, val message: String) : MpvEvent +} + +/** + * One mpv player drawing into [canvas]. It starts when the canvas is shown and shuts down when the + * canvas is removed, before its window is destroyed. Calls made before it started are run then. + * The app draws the controls; the mouse over the video is handled here and reported as [events]. + */ +class MpvPlayer internal constructor(private val lib: MpvLibrary) { + private val ctx: Pointer = lib.mpv_create() ?: error("mpv_create failed") + private var started = false + private var closed = false + private val pending = mutableListOf<() -> Unit>() + private var eventThread: Thread? = null + private val stopped = CountDownLatch(1) + /** The url being played, so a failure can be reported for the right link */ + @Volatile private var currentUrl: String? = null + /** Whether the current file finished loading. Subtitles can only be added to a loaded file */ + private var fileLoaded = false + /** From loadfile until the first frame plays */ + @Volatile private var opening = false + private val afterLoad = mutableListOf<() -> Unit>() + /** Where the file opening should start, in seconds, until it has started there */ + @Volatile private var resumeAt: Double? = null + /** The link's own audio tracks and the subtitle files added to the file, to add again when it is opened again */ + private var audioUrls = emptyList() + private val subtitlesAdded = mutableListOf>() + /** Since when, in ms, mpv has been waiting to play from a seek, 0 when it is not */ + private var stalledSince = 0L + /** How often the file was opened again for the seek waited on */ + private var reopens = 0 + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.receiveAsFlow() + + /** Called on every mouse move over the video, from the AWT thread */ + @Volatile var onMouseMove: (() -> Unit)? = null + + // A double click toggles full screen, so a single click waits a moment to be sure it is not one + private val singleClick = javax.swing.Timer( + ((Toolkit.getDefaultToolkit().getDesktopProperty("awt.multiClickInterval") as? Int) ?: 300).coerceAtMost(300), + ) { _events.trySend(MpvEvent.Click) }.apply { isRepeats = false } + + val canvas: Canvas = object : Canvas() { + override fun addNotify() { + super.addNotify() + start(Native.getComponentID(this)) + } + + override fun removeNotify() { + singleClick.stop() + shutdown() + super.removeNotify() + } + }.apply { + background = Color.BLACK + // Keys stay with the app, which handles them the same inside and outside the video + isFocusable = false + // Embedded mpv takes no input itself: its window is disabled, so the mouse reaches this canvas + val mouse = object : MouseAdapter() { + override fun mouseMoved(e: MouseEvent) { + onMouseMove?.invoke() + } + + override fun mouseDragged(e: MouseEvent) = mouseMoved(e) + + override fun mouseClicked(e: MouseEvent) { + // The mouse's back button, as everywhere else in the app + if (e.button == 4) _events.trySend(MpvEvent.Back) + if (e.button != MouseEvent.BUTTON1) return + when { + e.clickCount == 1 -> singleClick.restart() + e.clickCount == 2 -> { + singleClick.stop() + _events.trySend(MpvEvent.ToggleFullscreen) + } + } + } + + override fun mouseWheelMoved(e: MouseWheelEvent) { + // Handled here: otherwise the window passes the same turn on to the video again, a few + // times over, and one notch would move the level several steps + e.consume() + // A touchpad sends many small turns, where the whole notches in wheelRotation are 0: + // only the precise rotation says how far, and which way + val notches = -e.preciseWheelRotation + if (notches != 0.0) _events.trySend(MpvEvent.Wheel((e.x.toFloat() / width.coerceAtLeast(1)).coerceIn(0f, 1f), notches)) + } + } + addMouseListener(mouse) + addMouseMotionListener(mouse) + addMouseWheelListener(mouse) + } + + private fun start(windowId: Long): Unit = synchronized(this) { + if (started || closed) return@synchronized + started = true + option("wid", windowId.toString()) + // The app draws its own controls. mpv's would not work anyway: embedded, it gets no mouse input + option("osc", "no") + option("hwdec", "auto-safe") + // Stay on the last frame at the end instead of closing the file + option("keep-open", "yes") + option("idle", "yes") + option("force-window", "yes") + option("sub-auto", "no") + option("drag-and-drop", "no") + // VLC-parity buffer & network resilience + option("cache", "yes") + option("demuxer-readahead-secs", "30") + option("demuxer-max-bytes", "150MiB") + option("demuxer-max-back-bytes", "50MiB") + option("demuxer-lavf-probesize", "32768") + option("hr-seek", "default") + option("hr-seek-framedrop", "yes") + option("volume-max", "130") + // Network: a stream that stops answering fails after 30 s instead of 60, so the next source can + // play, and a dropped connection is opened again where it stopped + option("network-timeout", "30") + option("stream-lavf-o", "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5") + // Links come from the extensions already: no youtube-dl lookup before every file + option("ytdl", "no") + check("initialize", lib.mpv_initialize(ctx)) + // mpv's errors, such as a server refusing a request, go to the app's log + check("request_log_messages", lib.mpv_request_log_messages(ctx, "error")) + eventThread = Thread(::eventLoop, "mpv-events").apply { isDaemon = true; start() } + pending.forEach { it() } + pending.clear() + } + + /** Runs now if mpv started, otherwise once it has */ + private fun whenStarted(action: () -> Unit) = synchronized(this) { + when { + closed -> Unit + started -> action() + else -> pending += action + } + } + + private fun shutdown() { + synchronized(this) { + if (closed) return + closed = true + if (!started) { + lib.mpv_terminate_destroy(ctx) + return + } + command("quit") + } + // The event thread destroys the handle; wait so mpv lets go of the window before it is destroyed + if (!stopped.await(5, TimeUnit.SECONDS)) println("ERROR MpvPlayer: mpv did not shut down in time") + } + + private fun eventLoop() { + try { + while (true) { + val event = lib.mpv_wait_event(ctx, -1.0) + when (event.getInt(0)) { + MPV_EVENT_SHUTDOWN -> return + MPV_EVENT_LOG_MESSAGE -> { + // mpv_event_log_message: prefix, level, text + val data = event.getPointer(16) + val prefix = data.getPointer(0).getString(0, "UTF-8") + val text = data.getPointer(2L * Native.POINTER_SIZE).getString(0, "UTF-8") + println("ERROR mpv [$prefix]: ${text.trim()}") + } + MPV_EVENT_PLAYBACK_RESTART -> { + opening = false + resumeAt?.let(::checkResumed) + } + MPV_EVENT_FILE_LOADED -> synchronized(this) { + fileLoaded = true + afterLoad.forEach { it() } + afterLoad.clear() + } + MPV_EVENT_END_FILE -> { + val data = event.getPointer(16) + val reason = data.getInt(0) + val url = currentUrl ?: continue + if (reason == END_FILE_REASON_ERROR) opening = false + when (reason) { + END_FILE_REASON_EOF -> _events.trySend(MpvEvent.Ended) + END_FILE_REASON_ERROR -> _events.trySend(MpvEvent.Failed(url, lib.mpv_error_string(data.getInt(4)))) + } + } + } + } + } finally { + lib.mpv_terminate_destroy(ctx) + stopped.countDown() + } + } + + private fun option(name: String, value: String) = check(name, lib.mpv_set_option_string(ctx, name, value)) + + private fun command(vararg args: String): Int = lib.mpv_command(ctx, arrayOf(*args, null)).also { check(args.first(), it) } + + private fun check(what: String, code: Int) { + if (code < 0) println("ERROR MpvPlayer: $what failed: ${lib.mpv_error_string(code)}") + } + + private fun property(name: String): String? { + if (!started || closed) return null + val value = lib.mpv_get_property_string(ctx, name) ?: return null + return value.getString(0, "UTF-8").also { lib.mpv_free(value) } + } + + /** + * Plays a link with the headers the provider gave for it. The referer and user agent have their + * own mpv options, every other header is sent as is on every request, playlists and segments included. + */ + fun play(link: ExtractorLink, startSeconds: Double? = null, subtitles: List = emptyList()) = whenStarted { + val headers = link.headers.toMutableMap() + fun take(name: String) = headers.keys.firstOrNull { it.equals(name, ignoreCase = true) }?.let { headers.remove(it) } + val userAgent = take("User-Agent") ?: USER_AGENT + val headerReferer = take("Referer") + val referer = link.referer.ifBlank { headerReferer.orEmpty() } + + currentUrl = link.url + command("set", "user-agent", userAgent) + command("set", "referrer", referer) + // Appended one by one, so a comma inside a header value is never taken as a separator + command("change-list", "http-header-fields", "clr", "") + headers.forEach { (key, value) -> command("change-list", "http-header-fields", "append", "$key: $value") } + // keep-open pauses at the end of a file, which would carry over to the next one + command("set", "pause", "no") + audioUrls = link.audioTracks.map { it.url } + subtitlesAdded.clear() + reopens = 0 + load(link.url, startSeconds) + // A new file drops the subtitles added to the last one + subtitles.forEach { addSubtitle(it.file, it.title, it.select) } + } + + /** Opens [url], with the link's own audio tracks, at [startSeconds] or from the start */ + private fun load(url: String, startSeconds: Double?) { + command("set", "start", startSeconds?.let { "+$it" } ?: "none") + resumeAt = startSeconds?.takeIf { it > 0 } + fileLoaded = false + opening = true + stalledSince = 0 + afterLoad.clear() + command("loadfile", url, "replace") + audioUrls.forEach { command("audio-add", it, "auto") } + } + + /** + * A seek past what is loaded asks the server for that part of the file. Some servers never answer, + * and mpv would then wait there for good, the spinner turning. Once it waited [SEEK_PATIENCE_MS], + * the file is opened again at that point, which asks again on a new connection, with the audio + * track and subtitle that were chosen. Called with every status + */ + fun recoverStalledSeek() = whenStarted { + val waiting = fileLoaded && (opening || property("seeking") == "yes") + if (!waiting) { + stalledSince = 0 + if (fileLoaded) reopens = 0 + return@whenStarted + } + val now = System.currentTimeMillis() + if (stalledSince == 0L) stalledSince = now + if (now - stalledSince < SEEK_PATIENCE_MS || reopens >= MAX_REOPENS) return@whenStarted + val url = currentUrl ?: return@whenStarted + val at = property("time-pos")?.toDoubleOrNull() ?: return@whenStarted + reopens++ + println("WARNING MpvPlayer: no answer at ${at.toInt()} s for ${(now - stalledSince) / 1000} s, opening the file again there") + val audio = property("aid") + val subtitle = property("sid") + val subtitles = subtitlesAdded.toList() + load(url, at) + whenLoaded { + subtitles.forEach { (file, title) -> command("sub-add", file.absolutePath, "auto", title) } + audio?.let { command("set", "aid", it) } + subtitle?.let { command("set", "sid", it) } + } + } + + /** + * Once the file plays, checks it started where it was asked to. Some servers do not let mpv jump + * into the file as it opens, and it then starts from the beginning: it is asked to seek once more + */ + private fun checkResumed(wanted: Double) { + resumeAt = null + val at = property("time-pos")?.toDoubleOrNull() ?: return + val seekable = property("seekable") + if (at >= wanted - 5) { + println("INFO MpvPlayer: resumed at ${at.toInt()} s of ${wanted.toInt()} s") + return + } + println("WARNING MpvPlayer: started at ${at.toInt()} s, not ${wanted.toInt()} s (seekable: $seekable), seeking there") + command("seek", wanted.toString(), "absolute") + } + + /** Runs once the current file is loaded */ + private fun whenLoaded(action: () -> Unit) = whenStarted { + if (fileLoaded) action() else afterLoad += action + } + + /** Adds a subtitle file, shown when [select] is true */ + fun addSubtitle(file: File, title: String, select: Boolean = true) = whenLoaded { + subtitlesAdded += file to title + command("sub-add", file.absolutePath, if (select) "select" else "auto", title) + } + + /** Hides subtitles once the file is loaded, for a new link after the user turned them off */ + fun hideSubtitlesWhenLoaded() = whenLoaded { command("set", "sid", "no") } + + /** Shows a subtitle track, or hides subtitles when [id] is null */ + fun selectSubtitle(id: Int?) = whenStarted { command("set", "sid", id?.toString() ?: "no") } + + fun selectAudio(id: Int) = whenStarted { command("set", "aid", id.toString()) } + + fun togglePause() = whenStarted { command("cycle", "pause") } + + fun pause() = whenStarted { command("set", "pause", "yes") } + + fun seek(seconds: Double) = whenStarted { command("seek", seconds.toString(), "relative") } + + fun seekTo(seconds: Double) = whenStarted { command("seek", seconds.toString(), "absolute") } + + fun changeVolume(delta: Int) = whenStarted { command("add", "volume", delta.toString()) } + + fun setVolume(volume: Int) = whenStarted { command("set", "volume", volume.coerceIn(0, 130).toString()) } + + fun toggleMute() = whenStarted { command("cycle", "mute") } + + fun setSpeed(speed: Double) = whenStarted { command("set", "speed", speed.toString()) } + + /** How the picture fits the video's area: [AspectMode.Fill] crops it, [AspectMode.Stretch] distorts it */ + fun setAspectMode(mode: AspectMode) = whenStarted { + command("set", "keepaspect", if (mode == AspectMode.Stretch) "no" else "yes") + command("set", "panscan", if (mode == AspectMode.Fill) "1.0" else "0.0") + // mpv's zoom is in powers of 2 + command("set", "video-zoom", log2(mode.zoom).toString()) + command("set", "video-aspect-override", when (mode) { + AspectMode.SixteenNine -> "16:9" + AspectMode.FourThree -> "4:3" + else -> "-1" + }) + } + + /** + * Placed in the app's layout, where it takes no room: while it is there, [canvas] is in the + * window, over the app, where [place] puts it. The video is a native window that nothing can be + * drawn over. Inside the app's layout it would be the size of the part it shows, and change size + * whenever the bars came or went, and mpv drew the picture again at each new size, which flashed. + * Over the app it keeps its size, and only the part of it to show is cut out of it, at once. + */ + val anchor: JComponent = object : JComponent() { + private var layers: JLayeredPane? = null + + override fun addNotify() { + super.addNotify() + val pane = SwingUtilities.getRootPane(this)?.layeredPane ?: return + layers = pane + pane.add(canvas, JLayeredPane.PALETTE_LAYER as Any) + placeCanvas() + } + + override fun removeNotify() { + layers?.remove(canvas) + layers = null + super.removeNotify() + } + } + + /** Where the whole video goes, in the window's content coordinates */ + private var whole: Rectangle? = null + /** The part of it to show, in pixels from its corner, null for all of it */ + private var shown: Rectangle? = null + /** Whether [shown] was set on the canvas's window */ + private var shownSet = false + + /** + * Puts the video at [area], in the window's content coordinates, and shows only [visible] of it, + * in pixels from the corner of [area], null for all of it: what the app draws there shows instead. + */ + fun place(area: Rectangle, visible: Rectangle?) { + if (area != whole) { + whole = area + shownSet = false + placeCanvas() + } + if (visible != shown || !shownSet) { + shown = visible + showPart() + } + } + + private fun placeCanvas() { + val area = whole ?: return + if (canvas.parent == null) return + canvas.setBounds(area) + showPart() + } + + private fun showPart() { + val windows = Win32.instance ?: return + if (!canvas.isDisplayable) return + val hwnd = Native.getComponentPointer(canvas) ?: return + val region = shown?.let { windows.gdi.CreateRectRgn(it.x, it.y, it.x + it.width, it.y + it.height) } + // Windows owns the region from then on, and frees it + windows.user.SetWindowRgn(hwnd, region, true) + shownSet = true + } + + /** How subtitles look, from SubtitleStyle.mpvOptions. The one shown changes at once */ + fun setSubtitleStyle(options: List>) = whenStarted { + options.forEach { (name, value) -> command("set", name, value) } + } + + /** The languages, as codes, whose subtitle track in the video mpv shows on its own, for the next file */ + fun setSubtitleLanguages(codes: List) = whenStarted { command("set", "slang", codes.joinToString(",")) } + + fun changeSubDelay(seconds: Double) = whenStarted { command("add", "sub-delay", seconds.toString()) } + + fun resetSubDelay() = whenStarted { command("set", "sub-delay", "0") } + + /** + * Draws [ass] over the video, in ASS on a 1280 by 720 canvas, or removes it when null. The + * video is a native window, so this is the only way to show something on top of it. Each + * [layer] is drawn and removed on its own + */ + fun overlay(ass: String?, layer: Int = 1) = whenStarted { + if (ass == null) command("osd-overlay", layer.toString(), "none", "") + else command("osd-overlay", layer.toString(), "ass-events", ass, "1280", "720") + } + + /** The picture's brightness, from 0 for dark to 100 as the video is */ + fun setBrightness(percent: Int) = whenStarted { + // mpv's -100 is black: the darkest step still shows the picture, like a screen at its lowest + command("set", "brightness", ((percent.coerceIn(0, 100) - 100) * 0.8).toInt().toString()) + } + + fun status(): MpvStatus { + if (!started || closed) return MpvStatus() + val count = property("track-list/count")?.toIntOrNull() ?: 0 + val tracks = (0 until count).mapNotNull { i -> + val prefix = "track-list/$i/" + MpvTrack( + id = property("${prefix}id")?.toIntOrNull() ?: return@mapNotNull null, + type = property("${prefix}type") ?: return@mapNotNull null, + title = property("${prefix}title"), + lang = property("${prefix}lang"), + selected = property("${prefix}selected") == "yes", + external = property("${prefix}external") == "yes", + externalFilename = property("${prefix}external-filename"), + codec = property("${prefix}codec"), + channels = property("${prefix}demux-channel-count")?.toIntOrNull(), + default = property("${prefix}default") == "yes", + ) + } + return MpvStatus( + position = property("time-pos")?.toDoubleOrNull() ?: 0.0, + duration = property("duration")?.toDoubleOrNull() ?: 0.0, + paused = property("pause") == "yes", + buffering = opening || property("paused-for-cache") == "yes" || property("seeking") == "yes", + bufferPercent = property("cache-buffering-state")?.toIntOrNull(), + bufferedTo = property("demuxer-cache-time")?.toDoubleOrNull() ?: 0.0, + tracks = tracks, + volume = property("volume")?.toDoubleOrNull()?.toInt() ?: 100, + muted = property("mute") == "yes", + speed = property("speed")?.toDoubleOrNull() ?: 1.0, + subDelay = property("sub-delay")?.toDoubleOrNull() ?: 0.0, + ) + } + + private companion object { + const val MPV_EVENT_SHUTDOWN = 1 + const val MPV_EVENT_LOG_MESSAGE = 2 + const val MPV_EVENT_END_FILE = 7 + const val MPV_EVENT_FILE_LOADED = 8 + const val MPV_EVENT_PLAYBACK_RESTART = 21 + const val END_FILE_REASON_EOF = 0 + const val END_FILE_REASON_ERROR = 4 + /** A slow but working server answered a far seek within 8 s in tests: 12 s leaves it room */ + const val SEEK_PATIENCE_MS = 12_000L + /** Opened again this often for one seek, after which the server is left to answer */ + const val MAX_REOPENS = 2 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt new file mode 100644 index 00000000000..75b64b074d2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -0,0 +1,1218 @@ +package com.lagradost.cloudstream4.player + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.platform.LocalDensity +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream4.browse.Message +import com.lagradost.cloudstream4.browse.formatTime +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.arrow_back +import com.lagradost.cloudstream4.generated.resources.aspect_ratio_24px +import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_24 +import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_exit_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_audiotrack_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_hd_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_replay_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_speed_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_subtitles_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_volume_mute_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_volume_up_24 +import com.lagradost.cloudstream4.generated.resources.pause_24px +import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.library.SavedSource +import com.lagradost.cloudstream4.library.saveProgress +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream4.torrent.TorrServer +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import java.awt.Rectangle +import java.io.File +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt + +/** Which subtitle the user picked, so it is shown again when the source changes */ +private sealed interface SubtitleChoice { + /** Nothing picked: mpv's own choice */ + data object Default : SubtitleChoice + data object Off : SubtitleChoice + /** An extension's subtitle, by its url */ + data class Extension(val url: String) : SubtitleChoice +} + +/** + * Plays a movie or episode. The links load in the background and the best one starts on its own. + * The bar on top shows the title and source; the bar below plays, seeks, sets the volume and opens + * the subtitle, audio, speed and source pickers. In full screen the bars show while the mouse moves and hide after a few seconds. + * Space or K pauses, the left and right arrows or J and L go 10 s back and forward, the up and down + * arrows change the volume, F toggles full screen, M pauses and hides the app. Escape closes an open panel, then + * leaves full screen, then goes back. A click on the video closes an open panel, or pauses. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PlayerScreen( + viewModel: LinksViewModel, + fullscreen: Boolean, + onBack: () -> Unit, + onToggleFullscreen: () -> Unit, + /** Hands the player's key handler to the window, null when the player closes */ + onKeys: (((KeyEvent) -> Boolean)?) -> Unit = {}, + subtitleSettings: SubtitleSettingsStore = DataStoreSubtitleSettings.instance, +) { + val lib = Mpv.library + if (lib == null) { + Message("The video player is missing", Mpv.loadError) + return + } + val state by viewModel.state.collectAsState() + val player = remember { MpvPlayer(lib) } + var status by remember { mutableStateOf(MpvStatus()) } + val scope = rememberCoroutineScope() + var subtitleMessage by remember { mutableStateOf(null) } + var panel by remember { mutableStateOf(null) } + var aspectMode by remember { mutableStateOf(AspectMode.Fit) } + val currentFullscreen by rememberUpdatedState(onToggleFullscreen) + val currentBack by rememberUpdatedState(onBack) + + // Extension subtitles downloaded so far, by url, and which one is shown + val downloaded = remember { mutableStateMapOf() } + var subtitleChoice by remember { mutableStateOf(SubtitleChoice.Default) } + var loadingSubtitle by remember { mutableStateOf(null) } + + // Where playback is, in ms: it starts where the user left off, and a new source carries on from there + val tracking = viewModel.request.tracking + val playedTo = remember { longArrayOf(tracking?.startPositionMs ?: 0, 0) } + fun saveProgress(positionMs: Long = playedTo[0], durationMs: Long = playedTo[1]) { + if (tracking != null) runCatching { DataStoreWatchStore.instance.saveProgress(tracking, positionMs, durationMs) } + .onFailure { println("ERROR PlayerScreen: Could not save the playback position: $it") } + } + DisposableEffect(Unit) { onDispose { saveProgress() } } + + // Seconds really played on the current link, and the last position seen, to tell a link that + // ends right after it starts (a broken file) from one watched to the end + val playedOnLink = remember { doubleArrayOf(0.0, -1.0) } + val currentState by rememberUpdatedState(state) + + // Torrents stream through TorrServer, once the user agreed to it this session: mpv plays the + // stream's url, which failures are reported back from to the torrent's own link + val torrents = TorrServer.instance + var torrentAccepted by remember { mutableStateOf(TorrServer.accepted) } + var torrentStatus by remember { mutableStateOf(null) } + val torrentLinks = remember { HashMap() } + val torrentHashes = remember { mutableSetOf() } + DisposableEffect(Unit) { + onDispose { + val hashes = torrentHashes.toList() + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) + kotlinx.coroutines.GlobalScope.launch { hashes.forEach { torrents.drop(it) } } + } + } + + // A new link was picked, by hand or because the last one failed. The subtitle picked stays on + val selected = state.selectedLink + val askTorrent = selected != null && TorrServer.isTorrent(selected) && !torrentAccepted + LaunchedEffect(selected?.url, torrentAccepted) { + if (selected == null || askTorrent) return@LaunchedEffect + playedOnLink[0] = 0.0 + playedOnLink[1] = -1.0 + val playing = if (TorrServer.isTorrent(selected)) { + try { + if (!torrents.installed) torrents.install { done, total -> + torrentStatus = if (total > 0) "Downloading TorrServer ${done * 100 / total}%" else "Downloading TorrServer" + } + torrentStatus = "Connecting to peers…" + val stream = torrents.stream(selected) + torrentHashes += stream.hash + torrentLinks[stream.url] = selected.url + @Suppress("DEPRECATION") + ExtractorLink(selected.source, selected.name, stream.url, "", selected.quality, type = ExtractorLinkType.VIDEO) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + viewModel.onAction(LinksAction.Failed(selected.url, e.message ?: "The torrent could not start")) + return@LaunchedEffect + } finally { + torrentStatus = null + } + } else selected + val choice = subtitleChoice + val subtitles = state.subtitles.mapNotNull { sub -> + downloaded[sub.url]?.let { MpvSubtitle(it, sub.lang, select = choice == SubtitleChoice.Extension(sub.url)) } + } + player.play(playing, playedTo[0].takeIf { it > 0 }?.let { it / 1000.0 }, subtitles) + if (choice == SubtitleChoice.Off) player.hideSubtitlesWhenLoaded() + } + // In full screen the bars show on mouse moves and hide once the mouse rests, unless a panel is open + var activity by remember { mutableStateOf(0L) } + var barsShown by remember { mutableStateOf(true) } + // Showing or hiding the bars resizes what is under the mouse, which reports a move too: only a + // pointer that really moved on screen counts + val lastPointer = remember { arrayOfNulls(1) } + fun pointerMoved() { + val at = java.awt.MouseInfo.getPointerInfo()?.location ?: return + if (at == lastPointer[0]) return + lastPointer[0] = at + activity = System.nanoTime() + } + val blankCursor = remember { + val img = java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB) + java.awt.Toolkit.getDefaultToolkit().createCustomCursor(img, java.awt.Point(0, 0), "blank") + } + DisposableEffect(player) { + player.onMouseMove = ::pointerMoved + onDispose { + player.onMouseMove = null + player.canvas.cursor = java.awt.Cursor.getDefaultCursor() + } + } + LaunchedEffect(fullscreen, activity, panel) { + barsShown = true + if (fullscreen && panel == null) { + delay(3000) + barsShown = false + } + } + val showBars = !fullscreen || barsShown + LaunchedEffect(fullscreen, showBars) { + player.canvas.cursor = if (fullscreen && !showBars) blankCursor else java.awt.Cursor.getDefaultCursor() + } + // The mouse wheel over the video works as swipes do in the Android player: on the right half it + // sets the volume, on the left half the brightness, each shown by a level on the other side + var level by remember { mutableStateOf(null) } + var brightness by remember { mutableStateOf(100) } + val wheelRest = remember { doubleArrayOf(0.0) } + fun changeLevel(kind: LevelKind, delta: Int) { + // The level shown is newer than the status, which is read twice a second + val max = if (kind == LevelKind.Volume) 130 else 100 + val now = level?.takeIf { it.kind == kind }?.percent ?: if (kind == LevelKind.Volume) status.volume else brightness + val next = (now + delta).coerceIn(0, max) + when (kind) { + LevelKind.Volume -> { + if (status.muted && delta > 0) player.toggleMute() + player.setVolume(next) + } + LevelKind.Brightness -> { + brightness = next + player.setBrightness(next) + } + } + level = Level(kind, next, System.nanoTime()) + } + LaunchedEffect(level) { + val shown = level + if (shown == null) { + player.overlay(null, LEVEL_LAYER) + return@LaunchedEffect + } + player.overlay(levelAss(shown), LEVEL_LAYER) + delay(1500) + level = null + } + LaunchedEffect(player) { + player.events.collect { event -> + when (event) { + // 5 % a notch of the wheel, and a touchpad's small turns add up to it + is MpvEvent.Wheel -> { + wheelRest[0] += event.notches * 5 + val steps = wheelRest[0].toInt() + if (steps != 0) { + wheelRest[0] -= steps + changeLevel(if (event.x >= 0.5f) LevelKind.Volume else LevelKind.Brightness, steps) + } + } + MpvEvent.ToggleFullscreen -> currentFullscreen() + MpvEvent.Back -> currentBack() + MpvEvent.Click -> if (panel != null) panel = null else player.togglePause() + is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(torrentLinks[event.url] ?: event.url, event.message)) + MpvEvent.Ended -> { + val url = currentState.selected + // A file that stops within seconds of starting is broken: it must not count as watched + // unless it was started in its last half minute + val nearEnd = playedTo[1] > 0 && playedTo[0] >= playedTo[1] - 30_000 + if (playedOnLink[0] < 10 && !nearEnd && url != null) viewModel.onAction(LinksAction.Failed(url, "Stopped right after it started")) + else saveProgress(playedTo[1], playedTo[1]) + } + } + } + } + LaunchedEffect(player) { + var ticks = 0 + // The link whose source was saved as the title's last, once it played a few seconds + var sourceSaved: String? = null + while (true) { + status = player.status() + player.recoverStalledSeek() + val last = playedOnLink[1] + if (last >= 0 && status.position > last && status.position - last < 2) playedOnLink[0] += status.position - last + playedOnLink[1] = status.position + // Only once the link really plays: a broken one can jump to its end, which is no progress + if (status.duration > 0 && status.position > 0 && playedOnLink[0] >= 3) { + playedTo[0] = (status.position * 1000).toLong() + playedTo[1] = (status.duration * 1000).toLong() + val link = currentState.selectedLink + if (tracking != null && viewModel.request.localFile == null && link != null && link.url != sourceSaved) { + sourceSaved = link.url + runCatching { DataStoreWatchStore.instance.setLastSource(tracking.header.id, SavedSource(link.name, link.quality)) } + } + // Every 10 seconds + if (++ticks % 20 == 0) saveProgress() + } + delay(500) + } + } + + // While links are found and the video opens or buffers, a spinner turns over the video + val indicator = when { + torrentStatus != null -> torrentStatus + askTorrent -> null + state.exhausted -> null + state.selected == null -> if (state.links.isEmpty()) "Finding links…" else "Finding links · ${state.links.size} found" + status.buffering -> status.bufferPercent?.takeIf { it in 1..99 }?.let { "Buffering $it%" } ?: "Loading…" + else -> null + } + val currentIndicator by rememberUpdatedState(indicator) + LaunchedEffect(player, indicator != null) { + if (indicator == null) { + player.overlay(null) + return@LaunchedEffect + } + var step = 0 + while (true) { + currentIndicator?.let { player.overlay(spinnerAss(step++, it)) } + delay(80) + } + } + + fun showSubtitle(subtitle: SubtitleFile) { + subtitleMessage = null + subtitleChoice = SubtitleChoice.Extension(subtitle.url) + val file = downloaded[subtitle.url] + val track = file?.let { status.tracks.firstOrNull { track -> track.isFile(it) } } + if (track != null) { + player.selectSubtitle(track.id) + return + } + loadingSubtitle = subtitle.url + scope.launch { + runCatching { Subtitles.download(subtitle) } + .onSuccess { + downloaded[subtitle.url] = it + player.addSubtitle(it, subtitle.lang) + } + .onFailure { subtitleMessage = "Subtitle could not be loaded: ${it.message}" } + loadingSubtitle = null + } + } + + // The subtitle settings apply at once, also while a subtitle shows + val subtitleStyle by subtitleSettings.style.collectAsState() + LaunchedEffect(player, subtitleStyle) { player.setSubtitleStyle(subtitleStyle.mpvOptions()) } + val autoSelect by subtitleSettings.autoSelect.collectAsState() + LaunchedEffect(player, autoSelect) { player.setSubtitleLanguages(autoSelect?.let(SubtitleLanguages::codesOf).orEmpty()) } + + // Auto-select, as on Android: once the video plays, if it shows no subtitle in the chosen language, + // the first from the extension in that language shows. Only until one is picked or turned off by hand + var autoSelected by remember { mutableStateOf(false) } + val started = status.duration > 0 + LaunchedEffect(autoSelect, state.subtitles, started) { + val tag = autoSelect ?: return@LaunchedEffect + if (autoSelected || !started || subtitleChoice != SubtitleChoice.Default) return@LaunchedEffect + if (status.tracks.any { it.type == "sub" && it.selected && SubtitleLanguages.tagOf(it.lang) == tag }) { + autoSelected = true + return@LaunchedEffect + } + val subtitle = state.subtitles.sortedBy { it.lang.lowercase() }.firstOrNull { SubtitleLanguages.tagOf(it.lang) == tag } + ?: return@LaunchedEffect + autoSelected = true + showSubtitle(subtitle) + } + + // Keys reach the player through the window, before anything focused: the buttons that could hold + // the focus hide in full screen, which used to leave the keys going nowhere + fun onKey(event: KeyEvent): Boolean { + if (event.type != KeyEventType.KeyDown || event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) return false + when (event.key) { + Key.Spacebar, Key.K -> player.togglePause() + Key.DirectionLeft, Key.J -> player.seek(-10.0) + Key.DirectionRight, Key.L -> player.seek(10.0) + Key.DirectionUp -> changeLevel(LevelKind.Volume, 5) + Key.DirectionDown -> changeLevel(LevelKind.Volume, -5) + // The app hides at once, and the video must not carry on behind it: the window handles M next + Key.M -> { + player.pause() + return false + } + Key.F -> currentFullscreen() + Key.Escape -> if (panel != null) panel = null else return false + else -> return false + } + // What the key changed shows in the bars, also in full screen + if (event.key != Key.Escape) activity = System.nanoTime() + return true + } + DisposableEffect(Unit) { + onKeys(::onKey) + onDispose { onKeys(null) } + } + // Clicks on the bars outside their buttons close an open panel + val closePanel = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { panel = null } + // Moves over the bars, which the video does not see + val barMoves = Modifier.onPointerEvent(PointerEventType.Move) { pointerMoved() } + val topBar: @Composable (Modifier) -> Unit = { modifier -> + TopBar( + title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), + line = subtitleMessage ?: when { + state.exhausted && state.checkSites.isNotEmpty() -> + "More links wait on a check that you are human" + if (state.links.isNotEmpty()) ", the other ${state.links.size} could not be played" else "" + state.exhausted && state.links.isEmpty() -> "No playable links found" + + if (state.skipped > 0) " (${state.skipped} links cannot play on desktop, such as DRM)" else "" + state.exhausted -> "None of the ${state.links.size} links could be played" + state.selected == null -> "Finding links… ${state.links.size} found" + else -> state.selectedLink?.let(LinksViewModel::label) + }, + error = subtitleMessage != null || state.exhausted, + loading = state.loading, + onBack = onBack, + onRetry = if (state.exhausted) ({ viewModel.onAction(LinksAction.Retry) }) else null, + // Some extensions keep finding links for minutes: what is found so far can be enough + onSkip = if (state.loading && state.links.isNotEmpty()) ({ viewModel.onAction(LinksAction.Skip) }) else null, + modifier = modifier.then(closePanel).then(barMoves), + ) + } + val controlBar: @Composable (Modifier) -> Unit = { modifier -> + ControlBar( + player = player, + status = status, + state = state, + panel = panel, + aspectMode = aspectMode, + subtitleOn = status.tracks.any { it.type == "sub" && it.selected }, + fullscreen = fullscreen, + onPanel = { panel = it }, + onToggleFullscreen = onToggleFullscreen, + modifier = modifier.then(closePanel).then(barMoves), + ) + } + // The picture keeps its size and place whatever opens around it. In a window the bars sit above and + // below the video and stay. In full screen the video fills the screen, and the bars that come and + // go cover its edges, as do the panels in both. The video is a native window that nothing can be + // drawn over: it keeps its size, and the part they cover is cut out of it, see MpvPlayer.anchor + var area by remember { mutableStateOf(IntSize.Zero) } + var topBarHeight by remember { mutableStateOf(0) } + var controlBarHeight by remember { mutableStateOf(0) } + var sideWidth by remember { mutableStateOf(0) } + val overlaid = fullscreen && showBars + val open = panel?.takeIf { showBars } + val prompt = state.exhausted && state.checkSites.isNotEmpty() + val side = open != null || prompt || askTorrent + val visible = IntRect( + left = 0, + top = if (overlaid) topBarHeight else 0, + right = area.width - if (side) sideWidth else 0, + bottom = area.height - if (overlaid) controlBarHeight else 0, + ) + val density = LocalDensity.current + fun Int.dp() = with(density) { toDp() } + // Where the video's area is in the window, in pixels, from the last layout + val placed = remember { arrayOfNulls(1) } + fun placeVideo() { + val at = placed[0] ?: return + // The window's units are 1.25 pixels at 125 % scaling: the video's window is rounded outward, + // and the part to show is in its own pixels + val scale = density.density + val left = floor(at.left / scale).toInt() + val top = floor(at.top / scale).toInt() + val right = ceil(at.right / scale).toInt() + val bottom = ceil(at.bottom / scale).toInt() + val dx = at.left - left * scale + val dy = at.top - top * scale + val part = visible.takeIf { it != IntRect(0, 0, area.width, area.height) }?.let { + Rectangle((it.left + dx).roundToInt(), (it.top + dy).roundToInt(), it.width, it.height) + } + player.place(Rectangle(left, top, right - left, bottom - top), part) + } + SideEffect { placeVideo() } + Column( + Modifier + .fillMaxSize() + .background(Color.Black) + .then(barMoves), + ) { + if (!fullscreen) topBar(Modifier) + Box( + Modifier.fillMaxWidth().weight(1f).onSizeChanged { area = it }.onGloballyPositioned { + placed[0] = it.boundsInWindow() + placeVideo() + }, + ) { + SwingPanel(factory = { player.anchor }, modifier = Modifier.size(0.dp)) + if (overlaid) topBar(Modifier.align(Alignment.TopCenter).onSizeChanged { topBarHeight = it.height }) + if (overlaid) controlBar(Modifier.align(Alignment.BottomCenter).onSizeChanged { controlBarHeight = it.height }) + if (side) Row( + Modifier + .align(Alignment.TopEnd) + .padding(top = visible.top.dp(), bottom = (area.height - visible.bottom).dp()) + .fillMaxHeight() + .onSizeChanged { sideWidth = it.width }, + ) { + if (prompt) CheckPrompt( + sites = state.checkSites, + checking = state.checking, + onOpen = { viewModel.onAction(LinksAction.PassChecks) }, + ) + if (askTorrent) TorrentPrompt( + onPlay = { + TorrServer.accepted = true + torrentAccepted = true + }, + onSkip = { selected?.let { viewModel.onAction(LinksAction.Failed(it.url, "Torrent not played")) } }, + ) + if (open != null) SidePanel( + panel = open, + state = state, + status = status, + aspectMode = aspectMode, + downloaded = downloaded, + loadingSubtitle = loadingSubtitle, + onSelect = { viewModel.onAction(LinksAction.Select(it)) }, + onSubtitle = ::showSubtitle, + onSubtitleTrack = { id -> + subtitleChoice = if (id == null) SubtitleChoice.Off else SubtitleChoice.Default + player.selectSubtitle(id) + }, + onSubDelay = { if (it == 0.0) player.resetSubDelay() else player.changeSubDelay(it) }, + textSize = subtitleStyle.textSize, + onTextSize = { size -> subtitleSettings.setStyle(subtitleSettings.style.value.copy(fixedTextSize = size)) }, + onAudioTrack = player::selectAudio, + onSpeed = player::setSpeed, + onAspectMode = { mode -> + aspectMode = mode + player.setAspectMode(mode) + // The panel covers part of the picture: closed, the change shows + panel = null + }, + ) + } + } + if (!fullscreen) controlBar(Modifier) + } +} + +/** + * Asks before the first torrent of the session, as the Android app does: playing one shares it with + * other people, who see the user's IP address, and needs TorrServer the first time + */ +@Composable +private fun TorrentPrompt(onPlay: () -> Unit, onSkip: () -> Unit) { + Column( + Modifier.width(360.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Text("Play a torrent?", style = MaterialTheme.typography.titleLarge) + Text( + "This source is a torrent. It plays while it downloads from other people sharing it, and while it " + + "plays your connection shares it too: they can see your IP address.", + style = MaterialTheme.typography.bodyMedium, + ) + if (!TorrServer.instance.installed) Text( + "The first time, CloudStream downloads TorrServer (63 MB, github.com/YouROK/TorrServer) to stream it. " + + "Windows may then ask whether TorrServer can use the network: it plays either way, Allow lets more people connect.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + WhiteButton(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text("Play torrent") } + BlackButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { Text("Use another source") } + } +} + +/** + * Offered once nothing else plays: sites that only give their links after a check that a person has to + * pass, such as Cloudflare's tick box. The browser opens a window only when the user asks for it here + */ +@Composable +private fun CheckPrompt(sites: List, checking: Boolean, onOpen: () -> Unit) { + Column( + Modifier.width(360.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Text("Open a browser for more links?", style = MaterialTheme.typography.titleLarge) + Text( + "${if (sites.size == 1) "This site wants" else "These sites want"} to check that you are human " + + "before giving their links:", + style = MaterialTheme.typography.bodyMedium, + ) + Text(sites.joinToString(separator = "\n"), style = MaterialTheme.typography.bodyMedium) + Text( + "A browser window opens on each in turn. Tick the box there: the window closes by itself once the check " + + "is passed, and their links load here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + WhiteButton(onClick = onOpen, enabled = !checking, modifier = Modifier.fillMaxWidth()) { + Text(if (checking) "Waiting for the check…" else "Open browser") + } + } +} + +/** "English", or "English 1", "English 2" when there are several, by url */ +private fun subtitleLabels(subtitles: List): Map { + // Extensions spell a language with stray spaces at times, which must not make it a second language + fun name(sub: SubtitleFile) = sub.lang.trim().replace(Regex("\\s+"), " ") + val counts = subtitles.groupingBy(::name).eachCount() + val seen = HashMap() + return subtitles.associate { sub -> + val lang = name(sub) + val n = seen.merge(lang, 1, Int::plus)!! + sub.url to if (counts.getValue(lang) > 1) "$lang $n" else lang + } +} + +private fun host(url: String): String? = runCatching { java.net.URI(url).host?.removePrefix("www.") }.getOrNull() + +private fun MpvTrack.isFile(file: File) = external && externalFilename?.let { File(it).absolutePath.equals(file.absolutePath, ignoreCase = true) } == true + +private enum class Panel(val title: String) { Source("Source"), Subtitles("Subtitles"), Audio("Audio"), Speed("Speed"), Aspect("Aspect Ratio") } + +private val speeds = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0) + +private fun speedLabel(speed: Double) = "%s×".format(if (speed % 1.0 == 0.0) speed.toInt().toString() else speed.toString()) + +/** + * The player is always dark, like most video players: in the colours of a dark theme, the same as + * the rest of the app, and in neutral greys under a light theme. + */ +private object PlayerColors { + private val themeDark: Boolean + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.background.luminance() < 0.5f + val bar: Color + @Composable @ReadOnlyComposable get() = if (themeDark) MaterialTheme.colorScheme.background else Color(0xFF111114) + val panel: Color + @Composable @ReadOnlyComposable get() = if (themeDark) MaterialTheme.colorScheme.surfaceContainer else Color(0xFF18181C) + val content = Color.White + val secondary: Color + @Composable @ReadOnlyComposable get() = if (themeDark) MaterialTheme.colorScheme.onSurfaceVariant else Color.White.copy(alpha = 0.68f) + val track = Color(0x28FFFFFF) + val buffered = Color(0x38FFFFFF) + val selected = Color.White.copy(alpha = 0.10f) +} + +/** The app's accent, in the tone that reads on a dark background */ +@Composable +private fun accent(): Color { + val scheme = MaterialTheme.colorScheme + // A light theme's primary is dark, its inverse is the light tone of the same colour + return if (scheme.background.luminance() > 0.5f) scheme.inversePrimary else scheme.primary +} + +/** + * A spinner and a line of text, drawn by mpv over the video: twelve dots around a dim disc, the + * brightest one moving round as [step] grows. + */ +private fun spinnerAss(step: Int, text: String): String { + val cx = 640.0 + val cy = 330.0 + val lines = mutableListOf() + // Dim disc behind, so the spinner reads on a bright frame + lines += "{\\an7\\pos(${cx - 56},${cy - 56})\\bord0\\shad0\\1c&H000000&\\1a&H70&\\p1}" + circle(56.0) + "{\\p0}" + for (i in 0 until 12) { + val angle = Math.toRadians(i * 30.0 - 90) + val x = cx + 30 * kotlin.math.cos(angle) + val y = cy + 30 * kotlin.math.sin(angle) + // The head is opaque, the dots behind it fade out + val age = ((step - i) % 12 + 12) % 12 + val alpha = (age * 20).coerceAtMost(0xD0) + lines += "{\\an7\\pos(${x - 4.5},${y - 4.5})\\bord0\\shad0\\1c&HFFFFFF&\\1a&H%02X&\\p1}".format(alpha) + circle(4.5) + "{\\p0}" + } + lines += "{\\an8\\pos($cx,${cy + 70})\\fs24\\bord1.5\\shad0\\3c&H000000&\\1c&HFFFFFF&}" + text.replace("{", "(").replace("}", ")") + return lines.joinToString("\n") +} + +private enum class LevelKind { Volume, Brightness } + +/** A level set with the wheel or the keys, [at] telling apart two changes to the same percent */ +private data class Level(val kind: LevelKind, val percent: Int, val at: Long) + +/** The overlay layer of the levels, above the spinner's */ +private const val LEVEL_LAYER = 2 + +/** + * A level as the Android player shows it, drawn by mpv over the video: a thin bar filled from the + * bottom, an icon above and the percent below. The volume's on the left, the brightness's on the + * right, away from the hand that sets it. + */ +private fun levelAss(level: Level): String { + fun n(v: Double) = "%.1f".format(java.util.Locale.ROOT, v) + fun rect(w: Double, h: Double) = "m 0 0 l ${n(w)} 0 ${n(w)} ${n(h)} 0 ${n(h)}" + val volume = level.kind == LevelKind.Volume + val maxPercent = if (volume) 130 else 100 + val cx = if (volume) 110.0 else 1170.0 + val top = 260.0 + val height = 200.0 + val width = 6.0 + val edge = "\\bord1\\shad0\\3c&H000000&\\3a&H90&" + val lines = mutableListOf() + // The track, then the level from the bottom up + lines += "{\\an7\\pos(${n(cx - width / 2)},${n(top)})$edge\\1c&HFFFFFF&\\1a&HB0&\\p1}${rect(width, height)}{\\p0}" + val filled = (height * level.percent / maxPercent).coerceIn(0.0, height) + if (filled > 0) lines += "{\\an7\\pos(${n(cx - width / 2)},${n(top + height - filled)})\\bord0\\shad0\\1c&HFFFFFF&\\p1}${rect(width, filled)}{\\p0}" + // The icon, drawn on a 24 by 24 grid as Material's icons are + val icon = if (volume) { + // A speaker, with sound waves while there is any volume + "m 3 9 l 7 9 l 12 4 l 12 20 l 7 15 l 3 15" + if (level.percent > 0) { + " m 14 8.4 b 16.2 9.6 16.2 14.4 14 15.6 l 14.9 17.1 b 18.3 15.2 18.3 8.8 14.9 6.9" + + if (level.percent > 50) " m 16.4 4.6 b 21.4 7.6 21.4 16.4 16.4 19.4 l 17.3 20.9 b 23.4 17.2 23.4 6.8 17.3 3.1" else "" + } else "" + } else { + // A sun: a disc and eight rays + val rays = (0 until 8).joinToString(" ") { i -> + val angle = Math.toRadians(i * 45.0) + val x = 12 + 9 * kotlin.math.cos(angle) + val y = 12 + 9 * kotlin.math.sin(angle) + "m ${n(x - 1.3)} ${n(y - 1.3)} l ${n(x + 1.3)} ${n(y - 1.3)} ${n(x + 1.3)} ${n(y + 1.3)} ${n(x - 1.3)} ${n(y + 1.3)}" + } + "m 7.5 12 b 7.5 6 16.5 6 16.5 12 b 16.5 18 7.5 18 7.5 12 $rays" + } + lines += "{\\an7\\pos(${n(cx - 18)},${n(top - 48)})\\fscx150\\fscy150$edge\\1c&HFFFFFF&\\p1}$icon{\\p0}" + lines += "{\\an8\\pos(${n(cx)},${n(top + height + 12)})\\fs22\\bord1.5\\shad0\\3c&H000000&\\1c&HFFFFFF&}${level.percent}%" + return lines.joinToString("\n") +} + +/** An ASS drawing of a circle of radius [r], from 0,0 to 2r,2r */ +private fun circle(r: Double): String { + val k = r * 0.5523 + fun n(v: Double) = "%.1f".format(java.util.Locale.ROOT, v) + return "m ${n(r)} 0 b ${n(r + k)} 0 ${n(2 * r)} ${n(r - k)} ${n(2 * r)} ${n(r)} " + + "b ${n(2 * r)} ${n(r + k)} ${n(r + k)} ${n(2 * r)} ${n(r)} ${n(2 * r)} " + + "b ${n(r - k)} ${n(2 * r)} 0 ${n(r + k)} 0 ${n(r)} " + + "b 0 ${n(r - k)} ${n(r - k)} 0 ${n(r)} 0" +} + +@Composable +private fun TopBar( + title: String, + line: String?, + error: Boolean, + loading: Boolean, + onBack: () -> Unit, + onRetry: (() -> Unit)?, + onSkip: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + Row( + modifier.fillMaxWidth().background(PlayerColors.bar).padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onBack) { + Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back", tint = PlayerColors.content) + } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, color = PlayerColors.content, maxLines = 1, overflow = TextOverflow.Ellipsis) + line?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (error) MaterialTheme.colorScheme.error else PlayerColors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (loading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = PlayerColors.secondary) + if (onSkip != null) TextButton(onClick = onSkip) { Text("Skip", color = accent()) } + if (onRetry != null) TextButton(onClick = onRetry) { Text("Try again", color = accent()) } + } +} + +/** + * The bar under the video: a seek bar across the top showing what is played and what is loaded, + * then play, skip, volume and the time on the left, and the pickers and full screen on the right. + */ +@Composable +private fun ControlBar( + player: MpvPlayer, + status: MpvStatus, + state: LinksState, + panel: Panel?, + aspectMode: AspectMode = AspectMode.Fit, + subtitleOn: Boolean, + fullscreen: Boolean, + onPanel: (Panel?) -> Unit, + onToggleFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { + // While dragging, and after letting go until mpv got there, the bar shows the target, not the old position + var dragging by remember { mutableStateOf(null) } + var hovering by remember { mutableStateOf(null) } + var seekingTo by remember { mutableStateOf?>(null) } + LaunchedEffect(status) { + seekingTo?.let { (target, at) -> + if (abs(status.position - target) < 2 || System.currentTimeMillis() - at > 3000) seekingTo = null + } + } + val duration = status.duration.toFloat() + val position = dragging ?: seekingTo?.first ?: status.position.toFloat() + val accent = accent() + + Column(modifier.fillMaxWidth().background(PlayerColors.bar).padding(start = 12.dp, end = 12.dp, top = 6.dp, bottom = 4.dp)) { + SeekBar( + value = if (duration > 0) position / duration else 0f, + buffered = 0f, + enabled = duration > 0, + accent = accent, + onChange = { dragging = it * duration }, + onHover = { hovering = it?.times(duration) }, + onDone = { + dragging?.let { + player.seekTo(it.toDouble()) + seekingTo = it to System.currentTimeMillis() + } + dragging = null + }, + modifier = Modifier.fillMaxWidth(), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + PlayerIcon( + if (status.paused) Res.drawable.play_arrow_24px else Res.drawable.pause_24px, + if (status.paused) "Play" else "Pause", + size = 30.dp, + onClick = player::togglePause, + ) + SkipButton(forward = false) { player.seek(-10.0) } + SkipButton(forward = true) { player.seek(10.0) } + var volume by remember { mutableStateOf(null) } + PlayerIcon( + if (status.muted || status.volume == 0) Res.drawable.ic_baseline_volume_mute_24 else Res.drawable.ic_baseline_volume_up_24, + if (status.muted) "Unmute" else "Mute", + onClick = player::toggleMute, + ) + SeekBar( + value = (volume ?: status.volume.toFloat()) / 130f, + accent = accent, + onChange = { + volume = it * 130 + player.setVolume((it * 130).toInt()) + }, + onDone = { volume = null }, + modifier = Modifier.width(96.dp), + ) + // The hovered time shows in place of the position, to see where a click would go + val shown = hovering ?: position + Text( + if (duration > 0) "${formatTime((shown * 1000).toLong())} / ${formatTime((duration * 1000).toLong())}" else "", + style = MaterialTheme.typography.bodyMedium, + color = if (hovering != null) accent else PlayerColors.content, + modifier = Modifier.padding(start = 16.dp), + ) + Box(Modifier.weight(1f)) + + val subtitleTracks = state.subtitles.isNotEmpty() || status.tracks.any { it.type == "sub" } + PanelButton(Res.drawable.ic_baseline_subtitles_24, "Subtitles", panel == Panel.Subtitles, active = subtitleOn, enabled = subtitleTracks) { + onPanel(if (panel == Panel.Subtitles) null else Panel.Subtitles) + } + val audio = status.tracks.count { it.type == "audio" } + PanelButton( + Res.drawable.ic_baseline_audiotrack_24, "Audio", panel == Panel.Audio, + label = if (audio > 1) "$audio" else null, + ) { onPanel(if (panel == Panel.Audio) null else Panel.Audio) } + PanelButton( + Res.drawable.ic_baseline_speed_24, "Speed", panel == Panel.Speed, + label = if (status.speed != 1.0) speedLabel(status.speed) else null, + active = status.speed != 1.0, + ) { onPanel(if (panel == Panel.Speed) null else Panel.Speed) } + PanelButton( + Res.drawable.ic_baseline_hd_24, "Source", panel == Panel.Source, + label = state.selectedLink?.let { Qualities.getStringByInt(it.quality).ifBlank { null } } ?: "Source", + enabled = state.links.isNotEmpty(), + ) { onPanel(if (panel == Panel.Source) null else Panel.Source) } + PanelButton( + Res.drawable.aspect_ratio_24px, "Aspect Ratio", panel == Panel.Aspect, + label = if (aspectMode != AspectMode.Fit) aspectMode.label else null, + active = aspectMode != AspectMode.Fit, + ) { onPanel(if (panel == Panel.Aspect) null else Panel.Aspect) } + PlayerIcon( + if (fullscreen) Res.drawable.baseline_fullscreen_exit_24 else Res.drawable.baseline_fullscreen_24, + if (fullscreen) "Leave full screen" else "Full screen", + onClick = onToggleFullscreen, + ) + } + } +} + +@Composable +private fun PlayerIcon( + icon: DrawableResource, + description: String, + size: Dp = 24.dp, + tint: Color = PlayerColors.content, + enabled: Boolean = true, + onClick: () -> Unit, +) { + IconButton(onClick = onClick, enabled = enabled) { + Icon(painterResource(icon), contentDescription = description, tint = if (enabled) tint else tint.copy(alpha = 0.3f), modifier = Modifier.size(size)) + } +} + +/** Back or forward 10 seconds: the replay arrow with a 10 inside, mirrored to go forward */ +@Composable +private fun SkipButton(forward: Boolean, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Box(contentAlignment = Alignment.Center) { + Icon( + painterResource(Res.drawable.ic_baseline_replay_24), + contentDescription = if (forward) "Forward 10 seconds" else "Back 10 seconds", + tint = PlayerColors.content, + modifier = Modifier.size(28.dp).graphicsLayer { if (forward) scaleX = -1f }, + ) + Text("10", color = PlayerColors.content, fontSize = 8.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 2.dp)) + } + } +} + +/** Opens a picker: tinted while it is open or its setting is on, with a short label when there is one */ +@Composable +private fun PanelButton( + icon: DrawableResource, + description: String, + open: Boolean, + label: String? = null, + active: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val color = when { + !enabled -> PlayerColors.content.copy(alpha = 0.3f) + open || active -> accent() + else -> PlayerColors.content + } + Row( + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (open) PlayerColors.selected else Color.Transparent) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(painterResource(icon), contentDescription = description, tint = color, modifier = Modifier.size(22.dp)) + label?.let { Text(it, color = color, style = MaterialTheme.typography.labelLarge) } + } +} + +/** + * A thin bar that thickens under the mouse: [value] and [buffered] are fractions. Clicking or + * dragging calls [onChange] with the fraction under the mouse, [onDone] when the mouse is let go. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SeekBar( + value: Float, + accent: Color, + onChange: (Float) -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, + buffered: Float = 0f, + enabled: Boolean = true, + onHover: (Float?) -> Unit = {}, +) { + var width by remember { mutableStateOf(1) } + var hovered by remember { mutableStateOf(false) } + var pressed by remember { mutableStateOf(false) } + val currentChange by rememberUpdatedState(onChange) + val currentDone by rememberUpdatedState(onDone) + val currentHover by rememberUpdatedState(onHover) + fun fraction(x: Float) = (x / width).coerceIn(0f, 1f) + val thick = enabled && (hovered || pressed) + Box( + modifier + .height(20.dp) + .onSizeChanged { width = it.width.coerceAtLeast(1) } + .onPointerEvent(PointerEventType.Enter) { hovered = true } + .onPointerEvent(PointerEventType.Exit) { + hovered = false + currentHover(null) + } + .onPointerEvent(PointerEventType.Move) { if (enabled) currentHover(fraction(it.changes.first().position.x)) } + .pointerInput(enabled) { + if (!enabled) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown() + pressed = true + currentChange(fraction(down.position.x)) + drag(down.id) { change -> + currentChange(fraction(change.position.x)) + change.consume() + } + pressed = false + currentDone() + } + }, + contentAlignment = Alignment.CenterStart, + ) { + Canvas(Modifier.fillMaxWidth().height(if (thick) 6.dp else 3.dp)) { + val r = CornerRadius(size.height / 2) + drawRoundRect(PlayerColors.track, cornerRadius = r) + drawRoundRect(accent, size = Size(size.width * value.coerceIn(0f, 1f), size.height), cornerRadius = r) + } + if (thick) Canvas(Modifier.fillMaxWidth().height(14.dp)) { + drawCircle(accent, radius = size.height / 2, center = Offset(size.width * value.coerceIn(0f, 1f), size.height / 2)) + } + } +} + +@Composable +private fun SidePanel( + panel: Panel, + state: LinksState, + status: MpvStatus, + aspectMode: AspectMode = AspectMode.Fit, + downloaded: Map, + loadingSubtitle: String?, + onSelect: (String) -> Unit, + onSubtitle: (SubtitleFile) -> Unit, + onSubtitleTrack: (Int?) -> Unit, + onSubDelay: (Double) -> Unit, + textSize: Float, + /** null for the normal size */ + onTextSize: (Float?) -> Unit, + onAudioTrack: (Int) -> Unit, + onSpeed: (Double) -> Unit, + onAspectMode: (AspectMode) -> Unit = {}, +) { + LazyColumn(Modifier.width(380.dp).fillMaxHeight().background(PlayerColors.panel)) { + item { Text(panel.title, style = MaterialTheme.typography.titleMedium, color = PlayerColors.content, modifier = Modifier.padding(16.dp)) } + when (panel) { + // Names are shown whole: many sources differ only at the end of their name + Panel.Source -> items(state.links) { link -> + Choice( + text = LinksViewModel.label(link), + detail = state.failed[link.url]?.let { "Failed: $it" } ?: link.source.takeIf { it != link.name }, + selected = link.url == state.selected, + error = link.url in state.failed, + maxLines = Int.MAX_VALUE, + ) { onSelect(link.url) } + } + + Panel.Subtitles -> { + val tracks = status.tracks.filter { it.type == "sub" } + val shown = tracks.firstOrNull { it.selected } + // Timing first: a video can hold dozens of subtitle tracks + if (shown != null) item { SubtitleTiming(status.subDelay, onSubDelay) } + if (shown != null) item { SubtitleSize(textSize, onTextSize) } + item { Choice("Off", selected = shown == null) { onSubtitleTrack(null) } } + // Sorted by language. Extensions often give several of one: they are numbered and show where they come from + val subtitles = state.subtitles.sortedBy { it.lang.trim().lowercase() } + val labels = subtitleLabels(subtitles) + items(subtitles, key = { it.url }) { sub -> + val track = downloaded[sub.url]?.let { file -> tracks.firstOrNull { it.isFile(file) } } + Choice( + labels.getValue(sub.url), + detail = when { + loadingSubtitle == sub.url -> "Loading…" + !sub.url.contains("://") -> "Saved with the download" + else -> listOfNotNull("From the extension", host(sub.url)).joinToString(" · ") + }, + selected = track?.selected == true, + ) { onSubtitle(sub) } + } + items(tracks.filter { !it.external }) { track -> + Choice(track.label, detail = listOf("In the video", track.details).filter { it.isNotEmpty() }.joinToString(" · "), selected = track.selected) { + onSubtitleTrack(track.id) + } + } + } + + Panel.Audio -> { + val tracks = status.tracks.filter { it.type == "audio" } + items(tracks) { track -> + Choice( + track.label, + detail = listOf(if (track.external) "From the extension" else "In the video", track.details).filter { it.isNotEmpty() }.joinToString(" · "), + selected = track.selected, + ) { onAudioTrack(track.id) } + } + // Many sources carry one language each: say so, rather than leave an empty choice + if (tracks.size <= 1) item { + Text( + if (tracks.isEmpty()) "No audio track yet. It shows once the video starts." + else "This source has one audio track. Other sources may be in other languages: look in Source.", + style = MaterialTheme.typography.bodySmall, + color = PlayerColors.secondary, + modifier = Modifier.padding(16.dp), + ) + } + } + + Panel.Speed -> items(speeds) { speed -> + Choice(speedLabel(speed) + if (speed == 1.0) " (normal)" else "", selected = abs(status.speed - speed) < 0.01) { onSpeed(speed) } + } + + Panel.Aspect -> items(AspectMode.entries) { mode -> + Choice(mode.label, selected = aspectMode == mode) { onAspectMode(mode) } + } + } + } +} + +/** Moves the subtitles earlier or later when they are out of sync with the speech */ +@Composable +private fun SubtitleTiming(delay: Double, onChange: (Double) -> Unit) { + val accent = accent() + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text("Timing", style = MaterialTheme.typography.titleSmall, color = PlayerColors.content) + Text( + when { + abs(delay) < 0.05 -> "In sync with the video" + delay > 0 -> "Shown %.1f s later".format(delay) + else -> "Shown %.1f s earlier".format(-delay) + }, + style = MaterialTheme.typography.bodySmall, + color = PlayerColors.secondary, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { onChange(-0.5) }) { Text("Earlier", color = accent) } + TextButton(onClick = { onChange(0.5) }) { Text("Later", color = accent) } + if (abs(delay) >= 0.05) TextButton(onClick = { onChange(0.0) }) { Text("Reset", color = accent) } + } + } +} + +/** The text size, kept for every video. The rest of the look is in Settings > Player */ +@Composable +private fun SubtitleSize(size: Float, onChange: (Float?) -> Unit) { + val accent = accent() + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Size", style = MaterialTheme.typography.titleSmall, color = PlayerColors.content) + Text( + if (size == SubtitleStyle.DEFAULT_TEXT_SIZE) "Normal · more in Settings > Player" else "${size.toInt()} · more in Settings > Player", + style = MaterialTheme.typography.bodySmall, + color = PlayerColors.secondary, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { onChange((size - 3).coerceAtLeast(10f)) }, enabled = size > 10f) { Text("Smaller", color = accent) } + TextButton(onClick = { onChange((size + 3).coerceAtMost(60f)) }, enabled = size < 60f) { Text("Larger", color = accent) } + if (size != SubtitleStyle.DEFAULT_TEXT_SIZE) TextButton(onClick = { onChange(null) }) { Text("Reset", color = accent) } + } + } +} + +@Composable +private fun Choice( + text: String, + detail: String? = null, + selected: Boolean, + error: Boolean = false, + maxLines: Int = 2, + onClick: () -> Unit, +) { + val accent = accent() + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(if (selected) PlayerColors.selected else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = if (selected) accent else PlayerColors.content, + fontWeight = if (selected) FontWeight.SemiBold else null, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + detail?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (error) MaterialTheme.colorScheme.error else PlayerColors.secondary, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (selected) Text("✓", color = accent, modifier = Modifier.padding(start = 8.dp)) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt new file mode 100644 index 00000000000..b9b6f7170fe --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt @@ -0,0 +1,48 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream4.AppDirs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.lagradost.cloudstream4.network.DesktopHttp +import okhttp3.Request +import java.io.File +import java.io.IOException +import java.security.MessageDigest + +/** + * Downloads provider subtitles to the cache folder, so mpv opens a local file. Each subtitle can have + * its own headers, which mpv could only send by changing the headers of the video too. + */ +object Subtitles { + private val http get() = DesktopHttp.client + private val dir: File get() = AppDirs.cache.resolve("subtitles").toFile() + + suspend fun download(subtitle: SubtitleFile): File = withContext(Dispatchers.IO) { + // A subtitle saved beside a download is a file already + File(subtitle.url).takeIf { !subtitle.url.contains("://") && it.isFile }?.let { return@withContext it } + val name = MessageDigest.getInstance("SHA-1").digest(subtitle.url.toByteArray()).joinToString("") { "%02x".format(it) } + // mpv reads the format from the content, the extension only helps when it is known + val extension = subtitle.url.substringBefore('?').substringAfterLast('.', "").lowercase() + .takeIf { it in setOf("srt", "vtt", "ass", "ssa", "sub", "ttml", "dfxp") } ?: "sub" + val file = dir.resolve("$name.$extension") + if (file.isFile && file.length() > 0) return@withContext file + + dir.mkdirs() + val request = Request.Builder().url(subtitle.url).apply { + header("User-Agent", USER_AGENT) + subtitle.headers?.forEach { (key, value) -> header(key, value) } + }.build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code} for ${subtitle.url}") + val tmp = File(dir, "$name.tmp") + tmp.outputStream().use { response.body.byteStream().copyTo(it) } + if (!tmp.renameTo(file)) { + file.delete() + tmp.renameTo(file) + } + } + file + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt new file mode 100644 index 00000000000..449a92bea41 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt @@ -0,0 +1,268 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.AppData +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.AppVersion +import com.lagradost.cloudstream4.ElevationDeclinedException +import com.lagradost.cloudstream4.WindowsElevated +import com.lagradost.cloudstream4.desktopPreferences +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.theme.AppShapes +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton +import com.lagradost.cloudstream4.update.AppRelease +import com.lagradost.cloudstream4.update.AppUpdater +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.painterResource +import java.nio.file.Path +import kotlin.system.exitProcess + +/** Looks for a newer app version when the app starts */ +val checkAppUpdates = desktopPreferences.getBoolean("desktop_check_app_updates", true) + +private val updater = AppUpdater() + +/** + * Starts the app again. Installed, the executable is started before this one quits; the new one + * waits for files this one holds when it erases data. Run from a build, the app only quits. + */ +fun restartApp(): Nothing { + System.getProperty("jpackage.app-path")?.let { exe -> runCatching { ProcessBuilder(exe).start() } } + exitProcess(0) +} + +/** Downloading and installing a release, shared by the Updates screen and the prompt at startup */ +private class UpdateState { + var progress by mutableStateOf(null) + var error by mutableStateOf(null) + + suspend fun install(release: AppRelease) { + error = null + progress = 0f + try { + val installer = updater.download(release, Path.of(System.getProperty("java.io.tmpdir"), "CloudStream-update")) { progress = it } + // Windows asks for permission while the app is still in front, where its prompt is seen + withContext(Dispatchers.IO) { updater.startInstaller(installer, start = WindowsElevated::start) } + // The installer replaces the app's files, which it can only do once the app has quit + exitProcess(0) + } catch (_: ElevationDeclinedException) { + error = "Not updated: Windows needs your permission to install the update" + progress = null + } catch (t: Throwable) { + error = "Could not update: ${t.message ?: t}" + progress = null + } + } +} + +@Composable +private fun UpdateActions(release: AppRelease, state: UpdateState, onLater: (() -> Unit)? = null) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + state.progress?.let { + Text("Downloading the installer, CloudStream closes and updates when it is done", style = MaterialTheme.typography.bodySmall) + LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth()) + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + if (state.progress == null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WhiteButton(onClick = { scope.launch { state.install(release) } }) { Text("Update now") } + BlackButton(onClick = { runCatching { uriHandler.openUri(release.pageUrl) } }) { Text("What's new") } + onLater?.let { TextButton(onClick = it) { Text("Later") } } + } + } +} + +/** Asks to update when a newer version is out, once per start, if the user has not turned the check off */ +@Composable +fun AppUpdatePrompt() { + var release by remember { mutableStateOf(null) } + val state = remember { UpdateState() } + LaunchedEffect(Unit) { + if (!checkAppUpdates.get()) return@LaunchedEffect + release = runCatching { updater.findUpdate() } + .onFailure { println("WARNING AppUpdatePrompt: Could not check for an update: $it") } + .getOrNull() + } + val current = release ?: return + AlertDialog( + // dialog__window_background.xml + containerColor = MaterialTheme.colorScheme.background, + shape = AppShapes.dialog, + onDismissRequest = { if (state.progress == null) release = null }, + title = { Text("CloudStream ${current.version} is available") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("You have version ${AppVersion.current}.") + UpdateActions(current, state, onLater = { release = null }) + } + }, + confirmButton = {}, + ) +} + +object SettingsUpdatesScreen : SearchableSettings { + private sealed interface Check { + data object NotChecked : Check + data object Checking : Check + data class Done(val release: AppRelease?, val error: String? = null) : Check + } + + private var check by mutableStateOf(Check.NotChecked) + private val state = UpdateState() + + @Composable + override fun getTitleRes(): String = "Updates" + + @Composable + override fun getPreferences(): List { + val scope = rememberCoroutineScope() + fun checkNow() { + check = Check.Checking + scope.launch { + check = runCatching { Check.Done(updater.findUpdate()) } + .getOrElse { Check.Done(null, "Could not check: ${it.message ?: it}") } + } + } + return listOf( + Preference.PreferenceGroup( + title = "CloudStream", + preferenceItems = listOf( + Preference.PreferenceItem.CustomPreference("Version") { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val current = check + Text( + "Version ${AppVersion.current}" + when { + current is Check.Checking -> " · checking for updates" + current is Check.Done && current.error != null -> " · ${current.error}" + current is Check.Done && current.release == null -> " · up to date" + current is Check.Done -> " · version ${current.release!!.version} is available" + else -> "" + }, + modifier = Modifier.weight(1f), + ) + if ((check as? Check.Done)?.release == null) { + BlackButton(enabled = check != Check.Checking, onClick = ::checkNow) { Text("Check now") } + } + } + (check as? Check.Done)?.release?.let { UpdateActions(it, state) } + } + }, + Preference.PreferenceItem.SwitchPreference( + preference = checkAppUpdates, + title = "Check for updates when the app starts", + subtitle = "New versions come from github.com/${AppUpdater.REPOSITORY}", + ), + ), + ), + Preference.PreferenceGroup( + title = "Extensions", + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + preference = autoUpdateExtensions, + title = "Update extensions automatically", + subtitle = "Installs new versions of your extensions when the app starts", + ), + ), + ), + ) + } +} + +/** Clearing the cache, removing extensions or erasing everything, each confirmed and done on a restart */ +object SettingsStorageScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Storage" + + @Composable + override fun getPreferences(): List { + var confirm by remember { mutableStateOf(null) } + + confirm?.let { reset -> + AlertDialog( + // dialog__window_background.xml + containerColor = MaterialTheme.colorScheme.background, + shape = AppShapes.dialog, + onDismissRequest = { confirm = null }, + title = { Text("${reset.title}?") }, + text = { + Text( + when (reset) { + AppData.Reset.CACHE -> "Posters and other downloaded files are deleted and downloaded again when needed." + AppData.Reset.EXTENSIONS -> "Every installed extension and its files are removed. Your repositories, settings and library stay." + AppData.Reset.EVERYTHING -> "Settings, repositories, extensions and everything else are deleted, as if CloudStream had just been installed." + } + " CloudStream restarts to do this." + ) + }, + confirmButton = { + WhiteButton(onClick = { + AppData.instance.scheduleReset(reset) + restartApp() + }) { Text(reset.title) } + }, + dismissButton = { BlackButton(onClick = { confirm = null }) { Text("Cancel") } }, + ) + } + + fun item(reset: AppData.Reset, subtitle: String) = Preference.PreferenceItem.TextPreference( + title = reset.title, + subtitle = subtitle, + onClick = { confirm = reset }, + ) + + return listOf( + Preference.PreferenceGroup( + title = "Data", + preferenceItems = listOf( + Preference.PreferenceItem.InfoPreference("CloudStream keeps its data in ${AppDirs.root} and its cache in ${AppDirs.cache}."), + item(AppData.Reset.CACHE, "Posters and other files that can be downloaded again"), + item(AppData.Reset.EXTENSIONS, "Installed extensions and their files. Repositories, settings and the library stay"), + item(AppData.Reset.EVERYTHING, "Start fresh, as if CloudStream had just been installed"), + ), + ), + ) + } +} + +/** The Updates and Storage entries on the settings home */ +@Composable +fun maintenanceCategories(open: (SearchableSettings) -> Unit): List = listOf( + Preference.PreferenceItem.TextPreference( + title = "Updates", + subtitle = "Version ${AppVersion.current}", + icon = painterResource(Res.drawable.ic_baseline_system_update_24), + onClick = { open(SettingsUpdatesScreen) }, + ), + Preference.PreferenceItem.TextPreference( + title = "Storage", + subtitle = "Clear the cache, remove extensions or start fresh", + icon = painterResource(Res.drawable.ic_baseline_storage_24), + onClick = { open(SettingsStorageScreen) }, + ), +) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt new file mode 100644 index 00000000000..1ba8eda3027 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -0,0 +1,459 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.download.Downloads +import com.lagradost.cloudstream4.network.DesktopHttp +import com.lagradost.cloudstream4.rememberAppSettings +import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor +import com.lagradost.cloudstream4.theme.modeToTheme +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.collections.immutable.persistentListOf +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/* + * Desktop versions of the settings screens in app/ui/settings. They use the same AppSettings keys, + * but leave out what only applies to Android: TV layout, touch gestures, battery, rotation, PiP, + * APK updates and biometrics. Accounts, updates, backups and downloads come with the phases that + * add those features. + */ + +/** The settings home: one entry per category */ +class SettingsHomeScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.title_settings) + + @Composable + override fun getPreferences(): List = listOf( + category(SettingsGeneralScreen, Res.drawable.build_24px), + category(SettingsPlayerScreen, Res.drawable.play_arrow_24px), + category(SettingsLayoutScreen, Res.drawable.format_paint_24px), + category(SettingsProvidersScreen, Res.drawable.plugin_lang), + category(ExtensionsScreen(open), Res.drawable.extension_24px), + ) + maintenanceCategories(open) + + @Composable + private fun category(screen: SearchableSettings, icon: org.jetbrains.compose.resources.DrawableResource) = + Preference.PreferenceItem.TextPreference( + title = screen.getTitleRes(), + icon = painterResource(icon), + onClick = { open(screen) }, + ) +} + +object SettingsGeneralScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_general) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + val uriHandler = LocalUriHandler.current + val bananas = settings.general.bananas.get() + val downloadPath by settings.general.downloadPath.changes().collectAsState(settings.general.downloadPath.get()) + + fun link(title: String, url: String, icon: Painter) = Preference.PreferenceItem.TextPreference( + title = title, + subtitle = url, + icon = icon, + onClick = { runCatching { uriHandler.openUri(url) } }, + ) + + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_links), + preferenceItems = persistentListOf( + link( + stringResource(Res.string.github), + "https://github.com/recloudstream/cloudstream", + painterResource(Res.drawable.ic_github_logo) + ), + link( + stringResource(Res.string.lightnovel), + "https://github.com/LagradOst/QuickNovel", + painterResource(Res.drawable.quick_novel_icon) + ), + link( + stringResource(Res.string.discord), + "https://discord.gg/5Hus6fM", + painterResource(Res.drawable.ic_baseline_discord_24) + ), + link( + stringResource(Res.string.cs3wiki), + "https://cloudstream.miraheze.org/", + painterResource(Res.drawable.description_24px) + ), + ) + ), + Preference.PreferenceItem.ListPreference( + preference = settings.general.dns, + entries = DesktopHttp.dnsProviders.mapValues { (value, name) -> if (value == 0) stringResource(Res.string.none) else name }, + title = stringResource(Res.string.dns_pref), + subtitle = "%s · " + stringResource(Res.string.dns_pref_summary), + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(Res.string.download_path_pref), + subtitle = downloadPath.ifBlank { Downloads.defaultRoot().absolutePath }, + icon = painterResource(Res.drawable.folder_open_24px), + onClick = { chooseFolder(downloadPath.ifBlank { Downloads.defaultRoot().absolutePath })?.let { settings.general.downloadPath.set(it) } }, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(Res.string.benene), + subtitle = if (bananas == 0) { + stringResource(Res.string.benene_count_text_none) + } else { + stringResource(Res.string.benene_count_text, bananas) + }, + onClick = { settings.general.bananas.set(bananas + 1) }, + icon = painterResource(Res.drawable.benene), + ), + Preference.PreferenceItem.InfoPreference(title = stringResource(Res.string.legal_notice_text)), + ) + } +} + +object SettingsPlayerScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_player) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_player_features), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.resizeEnabled, + title = stringResource(Res.string.player_size_settings), + subtitle = stringResource(Res.string.player_size_settings_des), + icon = painterResource(Res.drawable.aspect_ratio_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.speedEnabled, + title = stringResource(Res.string.eigengraumode_settings), + subtitle = stringResource(Res.string.speed_setting_summary), + icon = painterResource(Res.drawable.ic_baseline_speed_24), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.autoPlayEnabled, + title = stringResource(Res.string.autoplay_next_settings), + subtitle = stringResource(Res.string.autoplay_next_settings_des), + icon = painterResource(Res.drawable.skip_next_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.startPaused, + title = stringResource(Res.string.start_paused_settings), + subtitle = stringResource(Res.string.start_paused_settings_des), + icon = painterResource(Res.drawable.pause_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.skipOpEnabled, + title = stringResource(Res.string.video_skip_op), + subtitle = stringResource(Res.string.enable_skip_op_from_database_des), + icon = painterResource(Res.drawable.keyboard_double_arrow_right_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.previewBarEnabled, + title = stringResource(Res.string.preview_seekbar), + subtitle = stringResource(Res.string.preview_seekbar_desc), + icon = painterResource(Res.drawable.picture_in_picture_center_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.episodeSync, + title = stringResource(Res.string.episode_sync_settings), + subtitle = stringResource(Res.string.episode_sync_settings_des), + icon = painterResource(Res.drawable.autorenew_24px) + ), + ) + ), + subtitlePreferences(), + ) + } +} + +object SettingsLayoutScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_ui) + + /** + * The values of R.array.themes_names_values, without Material You which needs Android 12. The + * names are the desktop's: "Black" is its Prime Video dark, the default, and "Light" the same in white + */ + private val themes = listOf( + "Black" to "Dark", "Light" to "Light", "System" to "System", "AmoledLight" to "Black", "Amoled" to "Amoled", + "Dracula" to "Dracula", "Lavender" to "Lavender Dreams", "SilentBlue" to "Silent Blue", + ) + + /** Same values as R.array.themes_overlay_names_values, without the Material You colors */ + private val primaryColors = listOf( + "Normal" to "Normal", "DandelionYellow" to "Dandelion Yellow", "CarnationPink" to "Carnation Pink", + "Orange" to "Orange", "DarkGreen" to "Dark Green", "Maroon" to "Maroon", "NavyBlue" to "Navy Blue", + "Grey" to "Grey", "White" to "White", "CoolBlue" to "Cool Blue", "Brown" to "Brown", "Blue" to "Cool", + "Red" to "Fire", "Purple" to "Burple", "Green" to "Green", "GreenApple" to "Apple", "Banana" to "Banana", + "Party" to "Party", "Pink" to "Pink Pain", "Lavender" to "Lavender", + ) + + @Composable + private fun RoundColor(color: Color) { + Box( + modifier = Modifier + .padding(start = 15.dp) + .size(20.dp) + .border(width = 1.5.dp, shape = CircleShape, color = MaterialTheme.colorScheme.onBackground) + .background(color, CircleShape) + ) + } + + @Composable + private fun SearchQuality.label(): String = stringResource( + when (this) { + SearchQuality.BlueRay -> Res.string.quality_blueray + SearchQuality.Cam -> Res.string.quality_cam + SearchQuality.CamRip -> Res.string.quality_cam_rip + SearchQuality.DVD -> Res.string.quality_dvd + SearchQuality.HD -> Res.string.quality_hd + SearchQuality.HQ -> Res.string.quality_hq + SearchQuality.HdCam -> Res.string.quality_cam_hd + SearchQuality.Telecine -> Res.string.quality_tc + SearchQuality.Telesync -> Res.string.quality_ts + SearchQuality.WorkPrint -> Res.string.quality_workprint + SearchQuality.SD -> Res.string.quality_sd + SearchQuality.FourK -> Res.string.quality_4k + SearchQuality.UHD -> Res.string.quality_uhd + SearchQuality.SDR -> Res.string.quality_sdr + SearchQuality.HDR -> Res.string.quality_hdr + SearchQuality.WebRip -> Res.string.quality_webrip + } + ) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + val theme by settings.ui.theme.changes().collectAsState(settings.ui.theme.get()) + + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_looks), + preferenceItems = persistentListOf( + // The theme follows these two settings live, see MainContent + Preference.PreferenceItem.ListPreference( + preference = settings.ui.primaryColor, + icon = painterResource(Res.drawable.colors_24px), + title = stringResource(Res.string.primary_color_settings), + entries = primaryColors.toMap(), + // "Normal" is the theme's own accent + iconProvider = { key, _ -> RoundColor(modeToTheme(desktopThemeMode(theme), desktopPrimaryColor(key)).primary) }, + ), + Preference.PreferenceItem.ListPreference( + preference = settings.ui.theme, + icon = painterResource(Res.drawable.palette_24px), + title = stringResource(Res.string.app_theme_settings), + entries = themes.toMap(), + iconProvider = { key, _ -> + RoundColor(modeToTheme(desktopThemeMode(key), CloudStreamPrimaryColor.NORMAL).background) + }, + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_ui_features), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.advancedSearch, + title = stringResource(Res.string.advanced_search), + subtitle = stringResource(Res.string.advanced_search_des), + icon = painterResource(Res.drawable.search_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.searchSuggestions, + title = stringResource(Res.string.search_suggestions), + subtitle = stringResource(Res.string.search_suggestions_des), + icon = painterResource(Res.drawable.tooltip_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.trailersEnabled, + title = stringResource(Res.string.show_trailers_settings), + icon = painterResource(Res.drawable.baseline_theaters_24) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.kitsuPostersEnabled, + title = stringResource(Res.string.kitsu_settings), + icon = painterResource(Res.drawable.kitsu_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.castEnabled, + title = stringResource(Res.string.show_cast_in_details), + icon = painterResource(Res.drawable.face_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.fillersEnabled, + title = stringResource(Res.string.show_fillers_settings), + icon = painterResource(Res.drawable.skip_next_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.showMetadataOverlay, + title = stringResource(Res.string.show_player_metadata_overlay), + icon = painterResource(Res.drawable.metadata_overlay_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.randomButtonEnabled, + title = stringResource(Res.string.random_button_settings), + subtitle = stringResource(Res.string.random_button_settings_desc), + icon = painterResource(Res.drawable.shuffle_24px) + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(Res.string.search_poster_img_des), + preferenceItems = persistentListOf( + Preference.PreferenceItem.MultiSelectListPreference( + preference = settings.ui.filterQuality, + title = stringResource(Res.string.pref_filter_search_quality), + icon = painterResource(Res.drawable.filter_alt_24px), + entries = SearchQuality.entries.associateWith { it.label() } + ), + dubSubPreference(), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.bottomTitle, + title = stringResource(Res.string.bottom_title_settings), + subtitle = stringResource(Res.string.bottom_title_settings_des), + icon = painterResource(Res.drawable.title_24px) + ), + Preference.PreferenceItem.SliderPreference( + preference = settings.ui.posterSize, + title = stringResource(Res.string.poster_size_settings), + subtitle = stringResource(Res.string.poster_size_settings_des), + valueRange = 0..15, + icon = painterResource(Res.drawable.baseline_grid_view_24), + ), + ), + ), + Preference.PreferenceGroup( + title = stringResource(Res.string.poster_ui_settings), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowRating, + title = stringResource(Res.string.show_rating), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowEpisode, + title = stringResource(Res.string.show_episode_text), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowTitle, + title = stringResource(Res.string.show_title), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowHd, + title = stringResource(Res.string.show_hd), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowDub, + title = stringResource(Res.string.show_dub), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowSub, + title = stringResource(Res.string.show_sub), + ), + ) + ), + ) + } +} + +object SettingsProvidersScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_providers) + + @Composable + private fun TvType.label(): String = stringResource( + when (this) { + TvType.TvSeries -> Res.string.tv_series_singular + TvType.Anime -> Res.string.anime_singular + TvType.OVA -> Res.string.ova_singular + TvType.AnimeMovie -> Res.string.movies_singular + TvType.Cartoon -> Res.string.cartoons_singular + TvType.Documentary -> Res.string.documentaries_singular + TvType.Movie -> Res.string.movies_singular + TvType.Torrent -> Res.string.torrent_singular + TvType.AsianDrama -> Res.string.asian_drama_singular + TvType.Live -> Res.string.live_singular + TvType.Others -> Res.string.other_singular + TvType.NSFW -> Res.string.nsfw_singular + TvType.Music -> Res.string.music_singular + TvType.AudioBook -> Res.string.audio_book_singular + TvType.CustomMedia -> Res.string.custom_media_singular + TvType.Audio -> Res.string.audio_singular + TvType.Podcast -> Res.string.podcast_singular + TvType.Video -> Res.string.video_singular + } + ) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + // Empty until plugins can be loaded on desktop, then each provider adds its language + val languages = APIHolder.apis.withLock { APIHolder.apis.map { it.lang }.distinct() }.sorted() + + return persistentListOf( + Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(Res.string.provider_lang_settings), + icon = painterResource(Res.drawable.plugin_lang), + entries = mapOf(AllLanguagesName to stringResource(Res.string.all_languages_preference)) + + languages.associateWith { it }, + preference = settings.provider.extensionLanguages + ), + Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(Res.string.preferred_media_settings), + icon = painterResource(Res.drawable.movie_edit_24px), + preference = settings.provider.preferredMedia, + entries = TvType.entries.associate { it.ordinal.toString() to it.label() } + ), + dubSubPreference(), + ) + } +} + +@Composable +private fun dubSubPreference() = Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(Res.string.display_subbed_dubbed_settings), + icon = painterResource(Res.drawable.audio_capture_24px), + preference = rememberAppSettings().provider.displayDubSub, + entries = mapOf( + DubStatus.None.name to stringResource(Res.string.none), + DubStatus.Dubbed.name to stringResource(Res.string.app_dubbed_text), + DubStatus.Subbed.name to stringResource(Res.string.app_subbed_text), + ) +) + +/** The system's folder picker, starting in [current]. Null when cancelled */ +private fun chooseFolder(current: String): String? { + val chooser = javax.swing.JFileChooser(java.io.File(current).takeIf { it.isDirectory } ?: java.io.File(System.getProperty("user.home"))).apply { + dialogTitle = "Download path" + fileSelectionMode = javax.swing.JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + } + return if (chooser.showDialog(null, "Use this folder") == javax.swing.JFileChooser.APPROVE_OPTION) chooser.selectedFile?.absolutePath else null +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt new file mode 100644 index 00000000000..10e7c77ab69 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream4.settings + +import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor +import com.lagradost.cloudstream4.theme.CloudStreamThemeMode + +/** Desktop version of perfToMode in shared/androidMain, without Material You */ +fun desktopThemeMode(value: String?): CloudStreamThemeMode = when (value) { + "System" -> CloudStreamThemeMode.FollowSystem + "Black" -> CloudStreamThemeMode.Dark + "Light" -> CloudStreamThemeMode.Light + "Amoled" -> CloudStreamThemeMode.Amoled + "AmoledLight" -> CloudStreamThemeMode.AmoledLight + "Dracula" -> CloudStreamThemeMode.Dracula + "Lavender" -> CloudStreamThemeMode.Lavender + "SilentBlue" -> CloudStreamThemeMode.SilentBlue + // "Black" and the default: the Prime Video dark + else -> CloudStreamThemeMode.Dark +} + +/** Desktop version of perfToColor in shared/androidMain, without Material You */ +fun desktopPrimaryColor(value: String?): CloudStreamPrimaryColor = when (value) { + "Blue" -> CloudStreamPrimaryColor.BLUE + "Purple" -> CloudStreamPrimaryColor.PURPLE + "Green" -> CloudStreamPrimaryColor.GREEN + "GreenApple" -> CloudStreamPrimaryColor.GREEN_APPLE + "Red" -> CloudStreamPrimaryColor.RED + "Banana" -> CloudStreamPrimaryColor.BANANA + "Party" -> CloudStreamPrimaryColor.PARTY + "Pink" -> CloudStreamPrimaryColor.PINK + "CarnationPink" -> CloudStreamPrimaryColor.CARNATION_PINK + "Maroon" -> CloudStreamPrimaryColor.MAROON + "DarkGreen" -> CloudStreamPrimaryColor.DARK_GREEN + "NavyBlue" -> CloudStreamPrimaryColor.NAVY_BLUE + "Grey" -> CloudStreamPrimaryColor.GREY + "White" -> CloudStreamPrimaryColor.WHITE + "Brown" -> CloudStreamPrimaryColor.BROWN + "Orange" -> CloudStreamPrimaryColor.ORANGE + "DandelionYellow" -> CloudStreamPrimaryColor.DANDELION_YELLOW + "CoolBlue" -> CloudStreamPrimaryColor.COOL_BLUE + "Lavender" -> CloudStreamPrimaryColor.LAVENDER + else -> CloudStreamPrimaryColor.NORMAL +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt new file mode 100644 index 00000000000..f5e3f8715a8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -0,0 +1,593 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp +import coil3.compose.rememberAsyncImagePainter +import com.lagradost.cloudstream3.plugins.Plugin +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream4.desktopPreferences +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.plugins.PluginUpdate +import com.lagradost.cloudstream4.plugins.RepoPlugin +import com.lagradost.cloudstream4.plugins.Repository +import com.lagradost.cloudstream4.plugins.RepositoryClient +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton +import com.mihon.common.preference.minusAssign +import com.mihon.common.preference.plusAssign +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import com.mihon.presentation.settings.collectAsState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** Repository URLs the user added, kept separate from the Android REPOSITORIES_KEY data store */ +private val repositories = desktopPreferences.getStringSet("desktop_repositories", emptySet()) + +/** Repository names from their repo.json, as "urlname", so the list shows them without loading every repository */ +private val repositoryNames = desktopPreferences.getStringSet("desktop_repository_names", emptySet()) + +/** Updates installed extensions when the app starts, like the Android app's automatic extension updates */ +val autoUpdateExtensions = desktopPreferences.getBoolean("desktop_auto_update_extensions", true) + +private val client = RepositoryClient() + +private fun knownNames(entries: Set): Map = entries.mapNotNull { entry -> + entry.split('\t', limit = 2).takeIf { it.size == 2 && it[1].isNotBlank() }?.let { it[0] to it[1] } +}.toMap() + +private fun rememberRepositoryName(url: String, name: String) { + if (name.isBlank() || name == url) return + val entry = "$url\t$name" + val current = repositoryNames.get() + if (entry in current) return + repositoryNames.set(current.filterNot { it.startsWith("$url\t") }.toSet() + entry) +} + +/** Until its repo.json is read, a GitHub repository is shown as "Repo (owner)" rather than the raw URL */ +private fun nameFromUrl(url: String): String { + val parts = url.removePrefix("https://").removePrefix("http://").split('/') + return if (parts.firstOrNull() == "raw.githubusercontent.com" && parts.size >= 3) "${parts[2]} (${parts[1]})" else shortUrl(url) +} + +private fun shortUrl(url: String) = url.removePrefix("https://").removePrefix("http://").removePrefix("raw.githubusercontent.com/") + +/** The name as the Android app shows it, which leaves out the "Provider" most names end with */ +private fun displayName(name: String) = name.removeSuffix("Provider") + +/** The extension's icon from its repository, or the extension icon when it has none or it fails to load */ +@Composable +private fun extensionIcon(iconUrl: String?): Painter { + val fallback = painterResource(Res.drawable.extension_24px) + if (iconUrl.isNullOrBlank()) return fallback + // The same sizes the Android app asks for + val url = iconUrl.replace("%size%", "64").replace("%exact_size%", "64") + return rememberAsyncImagePainter(url, placeholder = fallback, error = fallback, fallback = fallback) +} + +/** Windows draws no flag emoji, so the language is shown by name rather than with Android's flag */ +private fun languageName(code: String?): String? = + code?.takeIf { it.isNotBlank() }?.let { SubtitleHelper.fromTagToEnglishLanguageName(it) ?: it.uppercase() } + +private fun fileSize(bytes: Long?): String? = when { + bytes == null -> null + bytes < 1024 * 1024 -> "${(bytes + 1023) / 1024} KB" + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) +} + +/** Adds every repository in the community list, which is what MegaRepo does on Android. Returns how many were new */ +private suspend fun addCommunityRepositories(): Int { + val community = client.fetchCommunityRepositories() + val new = community.filterNot { it in repositories.get() } + repositories.set(repositories.get() + new) + return new.size +} + +/** + * Adds what the user typed: a repository URL, a cloudstreamrepo:// link or a short code such as + * "megarepo". MegaRepo itself is not added: its only plugin is an Android helper that adds the + * community repositories, so they are added directly instead. Returns a message for the user. + */ +private suspend fun addRepository(input: String): String { + val url = client.resolveRepositoryUrl(input) ?: return "No repository found for \"${input.trim()}\"" + val repository = client.fetchRepository(url) + val plugins = client.fetchPlugins(repository) + if (plugins.isNotEmpty() && plugins.all { it.internalName == RepositoryClient.MEGA_REPO_PLUGIN }) { + val added = addCommunityRepositories() + return "MegaRepo: added $added community repositories" + if (added == 0) ", you already had them all" else "" + } + rememberRepositoryName(url, repository.name) + repositories += url + return "Added ${repository.name.takeIf { it != url } ?: shortUrl(url)}" +} + +/** + * Extensions, as a menu of its three pages: repositories, installed extensions and adding a + * repository. Extensions built with `isCrossPlatform = true` run as they are, the others are + * converted from their Android build when they are installed. + */ +class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Extensions" + + @Composable + override fun getPreferences(): List { + val repos by repositories.collectAsState() + val installed by DesktopPluginManager.instance.installed.collectAsState() + val errors by DesktopPluginManager.instance.errors.collectAsState() + val icon = painterResource(Res.drawable.extension_24px) + return listOf( + Preference.PreferenceItem.TextPreference( + title = "Repositories", + subtitle = when (repos.size) { + 0 -> "None added yet" + 1 -> "1 repository" + else -> "${repos.size} repositories" + }, + icon = icon, + onClick = { open(RepositoriesScreen(open)) }, + ), + Preference.PreferenceItem.TextPreference( + title = "Installed extensions", + subtitle = listOfNotNull( + if (installed.isEmpty()) "None installed yet" else "${installed.size} installed", + errors.size.takeIf { it > 0 }?.let { "$it failed to load" }, + ).joinToString(" · "), + icon = painterResource(Res.drawable.build_24px), + onClick = { open(InstalledExtensionsScreen) }, + ), + Preference.PreferenceItem.TextPreference( + title = "Add repository", + subtitle = "A URL, a short code such as megarepo, or all community repositories", + icon = painterResource(Res.drawable.ic_baseline_add_24), + onClick = { open(AddRepositoryScreen) }, + ), + ) + } +} + +/** The repositories the user added, by name. Opening one lists its extensions */ +class RepositoriesScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Repositories" + + @Composable + override fun getPreferences(): List { + val repos by repositories.collectAsState() + val names by repositoryNames.collectAsState() + val known = knownNames(names) + + // Reads the names not known yet, a repository that fails to load keeps its name from the URL + LaunchedEffect(repos) { + val missing = repos.filter { it !in knownNames(repositoryNames.get()) } + coroutineScope { + missing.map { url -> + async { runCatching { client.fetchRepository(url) }.getOrNull()?.let { rememberRepositoryName(url, it.name) } } + }.awaitAll() + } + } + + if (repos.isEmpty()) return listOf( + Preference.PreferenceItem.InfoPreference("No repositories yet. Add one, or type megarepo to add all community repositories."), + Preference.PreferenceItem.TextPreference(title = "Add repository", onClick = { open(AddRepositoryScreen) }), + ) + + val items = repos.map { url -> url to (known[url] ?: nameFromUrl(url)) } + .sortedBy { it.second.lowercase() } + .map { (url, name) -> + Preference.PreferenceItem.TextPreference( + title = name, + subtitle = if (url == RepositoryClient.OFFICIAL_REPOSITORY) "Official repository · ${shortUrl(url)}" else shortUrl(url), + icon = painterResource(Res.drawable.extension_24px), + onClick = { open(RepositoryScreen(url)) }, + widget = { TextButton(onClick = { repositories -= url }) { Text("Remove") } }, + ) + } + return listOf(Preference.PreferenceGroup(title = "Repositories · ${repos.size}", preferenceItems = items)) + } +} + +/** Adding a repository by URL or short code, or the official or all community repositories */ +object AddRepositoryScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Add repository" + + @Composable + override fun getPreferences(): List { + val repos by repositories.collectAsState() + val scope = rememberCoroutineScope() + val addRepository = Preference.PreferenceItem.CustomPreference(title = "Add repository") { + var text by remember { mutableStateOf("") } + var adding by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + fun run(action: suspend () -> String) { + adding = true + message = null + scope.launch { + message = try { + action() + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + "Could not add it: ${t.message ?: t}" + } + adding = false + } + } + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("Repository URL or short code, for example megarepo") }, + ) + BusyOr(adding) { + WhiteButton( + enabled = text.isNotBlank(), + onClick = { + val input = text + run { addRepository(input).also { text = "" } } + }, + ) { Text("Add") } + } + } + message?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Row(Modifier.padding(horizontal = 8.dp)) { + if (RepositoryClient.OFFICIAL_REPOSITORY !in repos) { + TextButton(onClick = { repositories += RepositoryClient.OFFICIAL_REPOSITORY }) { + Text("Add the official repository") + } + } + TextButton(enabled = !adding, onClick = { + run { "Added ${addCommunityRepositories()} community repositories" } + }) { Text("Add all community repositories") } + } + } + } + return listOf(addRepository) + } +} + +/** Installed extensions, with their status, updates and uninstall */ +object InstalledExtensionsScreen : SearchableSettings { + private sealed interface Check { + data object NotChecked : Check + data object Checking : Check + data class Done(val updates: List, val message: String? = null) : Check + } + + private var check by mutableStateOf(Check.NotChecked) + + @Composable + override fun getTitleRes(): String = "Installed extensions" + + @Composable + override fun getPreferences(): List { + val manager = DesktopPluginManager.instance + val installed by manager.installed.collectAsState() + val errors by manager.errors.collectAsState() + val loadedNames by manager.loadedNames.collectAsState() + val scope = rememberCoroutineScope() + val busy = remember { mutableStateMapOf() } + + fun checkForUpdates() { + check = Check.Checking + scope.launch { + check = runCatching { Check.Done(manager.findUpdates()) } + .getOrElse { Check.Done(emptyList(), "Could not check: ${it.message ?: it}") } + } + } + + LaunchedEffect(Unit) { if (check == Check.NotChecked && installed.isNotEmpty()) checkForUpdates() } + val updates = (check as? Check.Done)?.updates.orEmpty() + // An update that was installed since the check is no longer one + .filter { update -> installed.any { it.internalName == update.installed.internalName && it.version < update.available.version } } + + val updatesGroup = Preference.PreferenceGroup( + title = "Updates", + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + preference = autoUpdateExtensions, + title = "Update extensions automatically", + subtitle = "Installs new versions of your extensions when the app starts", + ), + Preference.PreferenceItem.CustomPreference("Check for updates") { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val current = check + Text( + when { + current is Check.Checking -> "Checking your extensions' repositories" + current is Check.Done && current.message != null -> current.message + current is Check.Done && updates.isEmpty() -> "All extensions are up to date" + current is Check.Done -> if (updates.size == 1) "1 update available" else "${updates.size} updates available" + else -> "Not checked yet" + }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + ) + BusyOr(current is Check.Checking || busy["all"] == true, label = "Updating".takeIf { busy["all"] == true }) { + if (updates.isNotEmpty()) { + WhiteButton(onClick = { + busy["all"] = true + scope.launch { + val failed = manager.installUpdates(updates) + check = Check.Done(emptyList(), if (failed.isEmpty()) "Updated ${updates.size}" else "${failed.size} could not update: ${failed.values.first()}") + busy.remove("all") + } + }) { Text("Update all") } + } else { + BlackButton(enabled = installed.isNotEmpty(), onClick = ::checkForUpdates) { Text("Check now") } + } + } + } + }, + ), + ) + + val items = installed.sortedBy { displayName(it.name).lowercase() }.map { plugin -> + val error = errors[plugin.internalName] + val loaded = if (plugin.internalName in loadedNames) manager.loadedPlugin(plugin.internalName) else null + val providers = loaded?.providers?.size ?: 0 + val update = updates.firstOrNull { it.installed.internalName == plugin.internalName } + Preference.PreferenceItem.TextPreference( + title = displayName(plugin.name), + icon = extensionIcon(plugin.iconUrl), + subtitle = error?.let { "Failed to load: $it" } ?: listOfNotNull( + "Version ${plugin.version}" + (update?.let { " · version ${it.available.version} available" } ?: ""), + "Android build, converted for desktop".takeIf { plugin.isAndroid }, + // What could not be converted throws when used, so the sources needing it fail + when (plugin.disabledMethods) { + 0 -> null + 1 -> "1 part could not be converted, some sources may not work" + else -> "${plugin.disabledMethods} parts could not be converted, some sources may not work" + }, + when (providers) { + 0 -> null + 1 -> "1 provider" + else -> "$providers providers" + }, + "Its settings screen needs Android".takeIf { (loaded?.instance as? Plugin)?.openSettings != null }, + ).joinToString(" · "), + widget = { + BusyOr(busy[plugin.internalName] == true) { + fun run(action: suspend () -> Unit) { + busy[plugin.internalName] = true + scope.launch { + runCatching { action() } + busy.remove(plugin.internalName) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (update != null) WhiteButton(onClick = { run { manager.install(plugin.repositoryUrl, update.available) } }) { Text("Update") } + BlackButton(onClick = { run { manager.uninstall(plugin.internalName) } }) { Text("Uninstall") } + } + } + }, + ) + } + + return listOf( + updatesGroup, + Preference.PreferenceGroup( + title = "Installed · ${installed.size}", + preferenceItems = items.ifEmpty { + listOf(Preference.PreferenceItem.InfoPreference("No extensions installed yet. Open a repository to install some.")) + }, + ), + ) + } +} + +/** The plugins in one repository, with install, update and uninstall */ +class RepositoryScreen(private val url: String) : SearchableSettings { + private sealed interface State { + data object Loading : State + data class Failed(val message: String) : State + data class Loaded(val repository: Repository, val plugins: List) : State + } + + private var state by mutableStateOf(State.Loading) + + @Composable + override fun getTitleRes(): String = (state as? State.Loaded)?.repository?.name ?: "Repository" + + @Composable + override fun getPreferences(): List { + val manager = DesktopPluginManager.instance + val installed by manager.installed.collectAsState() + val scope = rememberCoroutineScope() + val busy = remember { mutableStateMapOf() } + val failures = remember { mutableStateMapOf() } + + LaunchedEffect(url) { + if (state is State.Loaded) return@LaunchedEffect + state = try { + val repository = client.fetchRepository(url) + rememberRepositoryName(url, repository.name) + State.Loaded(repository, client.fetchPlugins(repository)) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + State.Failed(t.message ?: t.toString()) + } + } + + return when (val current = state) { + State.Loading -> listOf(Preference.PreferenceItem.CustomPreference("Loading") { + CircularProgressIndicator(Modifier.padding(24.dp)) + }) + + is State.Failed -> listOf(Preference.PreferenceItem.InfoPreference("Could not load $url: ${current.message}")) + + is State.Loaded -> if (current.plugins.any { it.internalName == RepositoryClient.MEGA_REPO_PLUGIN }) { + listOf(megaRepoGroup(scope)) + } else { + val sorted = current.plugins.sortedBy { it.name.lowercase() } + val desktop = sorted.filter { it.supportsDesktop } + val android = sorted.filter { it.needsConversion } + val unavailable = sorted.filterNot { it.canInstall } + + @Composable + fun row(plugin: RepoPlugin): Preference.PreferenceItem<*, *> { + val installedVersion = installed.firstOrNull { it.internalName == plugin.internalName }?.version + return Preference.PreferenceItem.TextPreference( + // Like Android, a provider that is down says so in its name + title = displayName(plugin.name) + if (plugin.status == 0) " (disabled)" else "", + icon = extensionIcon(plugin.iconUrl), + subtitle = failures[plugin.internalName] + ?: listOfNotNull( + plugin.description, + languageName(plugin.language), + "v${plugin.version}", + fileSize(if (plugin.supportsDesktop) plugin.jarFileSize else plugin.androidFileSize), + ).joinToString(" · "), + widget = { + BusyOr(busy[plugin.internalName] == true, label = "Converting".takeIf { plugin.needsConversion }) { + fun run(action: suspend () -> Unit) { + busy[plugin.internalName] = true + failures.remove(plugin.internalName) + scope.launch { + runCatching { action() }.onFailure { + failures[plugin.internalName] = "Failed: ${it.message ?: it}" + } + busy.remove(plugin.internalName) + } + } + when { + installedVersion == null -> WhiteButton(onClick = { + run { manager.install(url, plugin) } + }) { Text("Install") } + + installedVersion < plugin.version -> WhiteButton(onClick = { + run { manager.install(url, plugin) } + }) { Text("Update") } + + else -> BlackButton(onClick = { + run { manager.uninstall(plugin.internalName) } + }) { Text("Uninstall") } + } + } + }, + ) + } + + // Product Sans draws "(5)" as a circled digit, so counts use a separator instead of parentheses + listOfNotNull( + if (desktop.isEmpty()) null else Preference.PreferenceGroup( + title = "Desktop builds · ${desktop.size}", + preferenceItems = desktop.map { row(it) }, + ), + if (android.isEmpty()) null else Preference.PreferenceGroup( + title = "Android builds · ${android.size}", + preferenceItems = listOf( + Preference.PreferenceItem.InfoPreference( + "These extensions were built for Android. Desktop converts them when you install " + + "them, which takes a few seconds. Searching and playing work for most; their " + + "settings screens do not. Sites behind Cloudflare open in Microsoft Edge in the background." + ) + ) + android.map { row(it) }, + ), + if (unavailable.isEmpty()) null else Preference.PreferenceGroup( + title = "Unavailable · ${unavailable.size}", + preferenceItems = unavailable.map { plugin -> + Preference.PreferenceItem.TextPreference( + title = displayName(plugin.name), + subtitle = "The repository lists no file to install", + enabled = false, + ) + }, + ), + ) + } + } + } +} + +/** MegaRepo holds no extensions, only an Android helper that adds the community repositories */ +@Composable +private fun megaRepoGroup(scope: CoroutineScope): Preference.PreferenceGroup { + var result by remember { mutableStateOf(null) } + var adding by remember { mutableStateOf(false) } + return Preference.PreferenceGroup( + title = "MegaRepo", + preferenceItems = listOf( + Preference.PreferenceItem.InfoPreference( + "MegaRepo has no extensions of its own. Its only plugin, MegaProvider, adds all the community " + + "repositories on Android. On desktop the app does that for you." + ), + Preference.PreferenceItem.CustomPreference("Add all community repositories") { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + BusyOr(adding) { + WhiteButton(onClick = { + adding = true + scope.launch { + result = runCatching { "Added ${addCommunityRepositories()} community repositories" } + .getOrElse { "Could not add them: ${it.message ?: it}" } + adding = false + } + }) { Text("Add all community repositories") } + } + result?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + }, + ), + ) +} + +@Composable +private fun BusyOr(busy: Boolean, label: String? = null, content: @Composable () -> Unit) { + if (busy) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + label?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) + } + } else { + content() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/SubtitleSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/SubtitleSettings.kt new file mode 100644 index 00000000000..e29c1bacf86 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/SubtitleSettings.kt @@ -0,0 +1,211 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.platform.SystemFont +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.player.DataStoreSubtitleSettings +import com.lagradost.cloudstream4.player.SubtitleSettingsStore +import com.lagradost.cloudstream4.player.SubtitleStyle +import com.mihon.common.preference.PreferenceData +import com.mihon.presentation.settings.Preference +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** + * The subtitle settings, in the Android app's keys: how subtitles look and which language shows on + * its own. An open player applies a change right away. + */ +@Composable +fun subtitlePreferences(store: SubtitleSettingsStore = DataStoreSubtitleSettings.instance): Preference.PreferenceGroup { + val style by store.style.collectAsState() + val fields = remember(store) { SubtitleFields(store) } + val normal = stringResource(Res.string.normal) + val fonts = remember { installedFonts() } + + return Preference.PreferenceGroup( + title = stringResource(Res.string.subtitles_settings), + preferenceItems = persistentListOf( + Preference.PreferenceItem.CustomPreference(title = "") { SubtitlePreview(style, stringResource(Res.string.subtitles_example_text)) }, + Preference.PreferenceItem.ListPreference( + preference = fields.autoSelect, + entries = mapOf(null to stringResource(Res.string.none)) + + SubtitleHelper.languages.sortedBy { it.languageName }.associate { it.IETF_tag to it.languageName }, + title = stringResource(Res.string.subs_auto_select_language), + subtitle = "%s · shows on its own when a video has it", + icon = painterResource(Res.drawable.ic_baseline_subtitles_24), + ), + Preference.PreferenceItem.SliderPreference( + preference = fields.textSize, + title = stringResource(Res.string.subs_font_size), + valueRange = 10..60, + valueString = style.fixedTextSize?.toInt()?.toString() ?: "$normal (${SubtitleStyle.DEFAULT_TEXT_SIZE.toInt()})", + ), + Preference.PreferenceItem.ListPreference( + preference = fields.font, + // Fonts the Android app has but this computer lacks are left out, unless one is picked already + entries = mapOf(null to normal) + SubtitleStyle.fonts + .filter { (name, family) -> family in fonts || name == style.font } + .mapValues { (_, family) -> if (family in fonts) family else "$family (not installed)" }, + title = stringResource(Res.string.subs_font), + ), + Preference.PreferenceItem.SwitchPreference(preference = fields.bold, title = stringResource(Res.string.all_subtitles_bold)), + Preference.PreferenceItem.SwitchPreference(preference = fields.italic, title = stringResource(Res.string.all_subtitles_italic)), + Preference.PreferenceItem.ColorPreference( + preference = fields.textColor, + title = stringResource(Res.string.subs_text_color), + icon = painterResource(Res.drawable.colors_24px), + ), + Preference.PreferenceItem.ListPreference( + preference = fields.edgeType, + entries = mapOf( + SubtitleStyle.EDGE_NONE to stringResource(Res.string.none), + SubtitleStyle.EDGE_OUTLINE to stringResource(Res.string.subtitles_outline), + SubtitleStyle.EDGE_DROP_SHADOW to stringResource(Res.string.subtitles_shadow), + SubtitleStyle.EDGE_RAISED to stringResource(Res.string.subtitles_raised), + SubtitleStyle.EDGE_DEPRESSED to stringResource(Res.string.subtitles_depressed), + ), + title = stringResource(Res.string.subs_edge_type), + icon = painterResource(Res.drawable.format_paint_24px), + ), + Preference.PreferenceItem.ColorPreference(preference = fields.edgeColor, title = stringResource(Res.string.subs_outline_color)), + Preference.PreferenceItem.ColorPreference( + preference = fields.backgroundColor, + title = stringResource(Res.string.subs_background_color), + subtitle = "A box behind the text. Fully transparent for none", + icon = painterResource(Res.drawable.palette_24px), + ), + Preference.PreferenceItem.SliderPreference( + preference = fields.elevation, + title = stringResource(Res.string.subs_subtitle_elevation), + subtitle = "Distance from the bottom of the video", + valueRange = 0..100, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(Res.string.reset_btn), + subtitle = "Back to how subtitles look at first", + icon = painterResource(Res.drawable.ic_baseline_replay_24), + // The text changes only the Android app applies stay as they are + onClick = { + val now = store.style.value + store.setStyle(SubtitleStyle(removeCaptions = now.removeCaptions, removeBloat = now.removeBloat, upperCase = now.upperCase)) + }, + ), + ), + ) +} + +/** The fonts installed on this computer, by family */ +private fun installedFonts(): Set = runCatching { + java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames.toSet() +}.getOrDefault(emptySet()) + +/** Each setting as a preference the settings widgets can show and change */ +private class SubtitleFields(private val store: SubtitleSettingsStore) { + private fun field(key: String, read: (SubtitleStyle) -> T, write: SubtitleStyle.(T) -> SubtitleStyle) = + StorePreference(key, store.style, read, { store.setStyle(store.style.value.write(it)) }, read(SubtitleStyle())) + + val autoSelect = StorePreference("subs_auto_select", store.autoSelect, { it }, store::setAutoSelect, SubtitleSettingsStore.DEFAULT_AUTO_SELECT) + val textSize = field("fixedTextSize", { it.textSize.toInt() }) { copy(fixedTextSize = it.toFloat()) } + val font = field("font", { it.font }) { copy(font = it, typefaceFilePath = null) } + val bold = field("bold", { it.bold }) { copy(bold = it) } + val italic = field("italic", { it.italic }) { copy(italic = it) } + val textColor = field("foregroundColor", { it.foregroundColor }) { copy(foregroundColor = it) } + val edgeType = field("edgeType", { it.edgeType }) { copy(edgeType = it) } + val edgeColor = field("edgeColor", { it.edgeColor }) { copy(edgeColor = it) } + val backgroundColor = field("backgroundColor", { it.backgroundColor }) { copy(backgroundColor = it) } + val elevation = field("elevation", { it.elevation }) { copy(elevation = it) } +} + +/** A part of a value held in a [StateFlow], read and written as a preference */ +private class StorePreference( + private val key: String, + private val source: StateFlow, + private val read: (S) -> T, + private val write: (T) -> Unit, + private val default: T, +) : PreferenceData { + override fun key() = key + override fun get() = read(source.value) + override fun set(value: T) = write(value) + override fun isSet() = get() != default + override fun delete() = write(default) + override fun defaultValue() = default + override fun changes(): Flow = source.map(read).distinctUntilChanged() + override fun stateIn(scope: CoroutineScope): StateFlow = changes().stateIn(scope, SharingStarted.Eagerly, get()) +} + +/** The example text as the player shows it, over a dark frame */ +@OptIn(ExperimentalTextApi::class) +@Composable +private fun SubtitlePreview(style: SubtitleStyle, text: String) { + val family = style.font?.let { SubtitleStyle.fonts[it] }?.let { FontFamily(SystemFont(it)) } ?: FontFamily.SansSerif + val base = TextStyle( + fontSize = (style.textSize * 0.8f).sp, + fontFamily = family, + fontWeight = if (style.bold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (style.italic) FontStyle.Italic else FontStyle.Normal, + textAlign = TextAlign.Center, + ) + val edge = Color(style.edgeColor) + val shadow = when (style.edgeType) { + SubtitleStyle.EDGE_DROP_SHADOW -> Shadow(edge, Offset(3f, 3f), 2f) + SubtitleStyle.EDGE_RAISED -> Shadow(edge, Offset(1.5f, 1.5f), 0f) + SubtitleStyle.EDGE_DEPRESSED -> Shadow(edge, Offset(-1.5f, -1.5f), 0f) + else -> null + } + Box( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(140.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Brush.verticalGradient(listOf(Color(0xFF3A4A5E), Color(0xFF151A22)))), + ) { + Box( + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = (style.elevation * 0.6f).dp.coerceAtMost(100.dp), start = 16.dp, end = 16.dp) + .background(Color(style.backgroundColor).takeIf { it.alpha > 0f } ?: Color(style.windowColor)), + ) { + if (style.edgeType == SubtitleStyle.EDGE_OUTLINE) { + Text(text, style = base.copy(color = edge, drawStyle = Stroke(width = (style.edgeSize ?: 4f)))) + } + Text(text, style = base.copy(color = Color(style.foregroundColor), shadow = shadow)) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6f0ee55ae2..45afe47373f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,13 @@ # https://docs.gradle.org/current/userguide/plugins.html#sec:version_catalog_plugin_application # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] +haze = "1.7.3" activity = "1.13.0" androidGradlePlugin = "9.1.1" animeDb = "1.0.2" annotation = "1.10.0" appcompat = "1.7.1" +asm = "9.10.1" # Same version dex2jar brings in biometric = "1.4.0-alpha07" buildkonfigGradlePlugin = "0.21.2" coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later @@ -15,12 +17,15 @@ constraintlayout = "2.2.1" coreKtx = "1.18.0" cryptography = "0.6.0" desugar_jdk_libs_nio = "2.1.5" +dex2jar = "2.4.38" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" +gson = "2.14.0" instancioCore = "5.6.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20260522" +jna = "5.19.1" jsoup = "1.22.1" junit = "4.13.2" junitKtx = "1.3.0" @@ -42,6 +47,7 @@ navigationKtx = "2.9.8" newpipeextractor = "v0.26.3" nextlibMedia3 = "1.9.3-0.12.0" nicehttp = "0.4.18" +okhttp = "5.3.2" # Same version NiceHttp brings in overlappingpanels = "0.1.5" paletteKtx = "1.0.0" preferenceKtx = "1.2.1" @@ -72,11 +78,13 @@ versionCode = "68" versionName = "4.8.0" [libraries] +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity" } anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeDb" } annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "asm" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } @@ -90,12 +98,15 @@ cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", ver cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" } databinding = { module = "androidx.databinding:viewbinding", version.ref = "androidGradlePlugin" } desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" } +dex2jar = { module = "de.femtopedia.dex2jar:dex-translator", version.ref = "dex2jar" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } instancio-core = { group = "org.instancio", name = "instancio-core", version.ref = "instancioCore" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } json = { module = "org.json:json", version.ref = "json" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } @@ -134,6 +145,8 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version nextlib-media3ext = { module = "io.github.anilbeesetti:nextlib-media3ext", version.ref = "nextlibMedia3" } nextlib-mediainfo = { module = "io.github.anilbeesetti:nextlib-mediainfo", version.ref = "nextlibMedia3" } nicehttp = { module = "com.github.Blatzar:NiceHttp", version.ref = "nicehttp" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" } overlappingpanels = { module = "com.github.discord:OverlappingPanels", version.ref = "overlappingpanels" } palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index ca024b463a1..8977e1d7ef5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -2598,7 +2598,8 @@ fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") { * 2. If it has time but no offset (e.g. "2026-05-17 14:35"), fall back to device timezone * 3. If it's date-only (e.g. "2026-05-17"), use start of day in device timezone */ - runCatching { components.toInstantUsingOffset().toEpochMilliseconds() } + // toInstantUsingOffset reads a missing offset as UTC, so only use it when one was parsed + runCatching { components.also { requireNotNull(it.offsetHours) }.toInstantUsingOffset().toEpochMilliseconds() } .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() } .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() } } diff --git a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/DesktopWebView.kt b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/DesktopWebView.kt new file mode 100644 index 00000000000..f11f1ff4fd4 --- /dev/null +++ b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/DesktopWebView.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.network + +import okhttp3.Request +import okhttp3.Response + +/** + * The browser behind WebViewResolver on desktop, which has no Android WebView. The desktop app + * installs one in [DesktopWebView.engine]; without one, WebViewResolver finds nothing and requests + * go through unchanged. + */ +interface DesktopWebView { + /** The browser's own user agent, which cookies it earned only work with */ + val userAgent: String? + + /** + * Loads [request] in a new page and reports each request the page makes to [onRequest], until + * [onRequest] returns true or [timeoutMs] passes. [script] runs after each request, its result + * going to [onScript]. [userAgent] replaces the browser's own when set. + */ + suspend fun load( + request: Request, + userAgent: String?, + script: String?, + onScript: ((String) -> Unit)?, + timeoutMs: Long, + onRequest: (Request) -> Boolean, + ) + + /** The browser's cookies for [url] as a Cookie header, like Android's CookieManager.getCookie */ + fun cookies(url: String): String? + + /** + * Makes [request] from inside the browser, on a page of the same site, for sites that refuse + * any other client even with the browser's cookies. Null when the browser could not make it + */ + suspend fun fetch(request: Request): Response? + + companion object { + @Volatile + var engine: DesktopWebView? = null + } +} diff --git a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt index 39e0d51f95e..808b0574951 100644 --- a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt +++ b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt @@ -1,11 +1,17 @@ package com.lagradost.cloudstream3.network +import com.lagradost.api.Log import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.nicehttp.requestCreator import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response +import java.util.Collections +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.runBlocking + +private const val TAG = "WebViewResolver" /** * When used as Interceptor additionalUrls cannot be returned, use WebViewResolver(...).resolveUsingWebView(...) @@ -18,24 +24,33 @@ import okhttp3.Response * @param timeout close webview after timeout * */ actual class WebViewResolver actual constructor( - interceptUrl: Regex, - additionalUrls: List, - userAgent: String?, - useOkhttp: Boolean, - script: String?, - scriptCallback: ((String) -> Unit)?, - timeout: Long + val interceptUrl: Regex, + val additionalUrls: List, + val userAgent: String?, + // The browser makes every request itself: desktop has no way to hand them to okhttp + @Suppress("unused") val useOkhttp: Boolean, + val script: String?, + val scriptCallback: ((String) -> Unit)?, + val timeout: Long ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() - return chain.proceed(request) + return runBlocking { + val fixedRequest = resolveUsingWebView(request).first + chain.proceed(fixedRequest ?: request) + } } actual companion object { actual val DEFAULT_TIMEOUT = 60_000L actual var webViewUserAgent: String? = null + get() = field ?: DesktopWebView.engine?.userAgent?.also { field = it } + + /** The Android app's, which Android extensions call */ + @JvmName("getWebViewUserAgent1") + fun getWebViewUserAgent(): String? = webViewUserAgent } actual suspend fun resolveUsingWebView( @@ -64,11 +79,43 @@ actual class WebViewResolver actual constructor( } } + /** As on Android: stops at the first request matching [interceptUrl], collecting those matching [additionalUrls] */ actual suspend fun resolveUsingWebView( request: Request, requestCallBack: (Request) -> Boolean ): Pair> { - TODO("Not yet implemented") + val engine = DesktopWebView.engine + if (engine == null) { + Log.w(TAG, "No browser to load ${request.url}") + return null to emptyList() + } + var fixedRequest: Request? = null + val extraRequestList = Collections.synchronizedList(ArrayList()) + try { + engine.load(request, userAgent, script, scriptCallback, timeout) { seen -> + val url = seen.url.toString() + when { + interceptUrl.containsMatchIn(url) -> { + fixedRequest = seen + requestCallBack(seen) + true + } + + additionalUrls.any { it.containsMatchIn(url) } -> { + extraRequestList.add(seen) + requestCallBack(seen) + } + + else -> false + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logError(e) + } + engine.userAgent?.let { webViewUserAgent = it } + return fixedRequest to extraRequestList.toList() } } diff --git a/settings.gradle.kts b/settings.gradle.kts index dd92448b069..332adf8b63b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,4 +18,4 @@ dependencyResolutionManagement { } rootProject.name = "CloudStream" -include(":app", ":shared", ":library", ":docs", ":desktopApp") +include(":app", ":shared", ":library", ":docs", ":desktopApp", ":androidCompat") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6bca3488e24..3c33bc564f4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -50,7 +50,19 @@ kotlin { } jvmMain.dependencies { + api(project(":androidCompat")) + implementation(libs.jackson.module.kotlin) implementation(libs.ktor.client.java) + implementation(libs.kotlinx.serialization.json) + implementation(libs.okhttp) + implementation(libs.okhttp.dnsoverhttps) + // For the extensions' client, app.baseClient + implementation(libs.nicehttp) + } + + jvmTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } } @@ -63,4 +75,17 @@ compose.resources { publicResClass = true packageOfResClass = "com.lagradost.cloudstream4.generated.resources" generateResClass = auto -} \ No newline at end of file +} +// The Android extensions test converts and loads hundreds of extensions in one run +tasks.withType().configureEach { + // -PtestHeap=1g runs them with the memory the app has + maxHeapSize = providers.gradleProperty("testHeap").getOrElse("3g") + // Tests keep their files, such as the HTTP cache, away from the real app's folders + systemProperty("cloudstream.home", layout.buildDirectory.dir("test-home").get().asFile.absolutePath) + // Tests that start the installed Edge or Chrome, off by default: gradlew :shared:jvmTest -PbrowserTests + systemProperty("cloudstream.browserTests", providers.gradleProperty("browserTests").isPresent.toString()) + systemProperty("cloudstream.cloudflareTest", providers.gradleProperty("cloudflareTest").isPresent.toString()) + systemProperty("cloudstream.cloudflareUrl", providers.gradleProperty("cloudflareUrl").getOrElse("")) + // Downloads TorrServer and streams a public torrent, off by default: gradlew :shared:jvmTest -PtorrentTests + systemProperty("cloudstream.torrentTests", providers.gradleProperty("torrentTests").isPresent.toString()) +} diff --git a/shared/src/commonMain/composeResources/drawable/aspect_ratio_24px.xml b/shared/src/commonMain/composeResources/drawable/aspect_ratio_24px.xml new file mode 100644 index 00000000000..8c8765ba80b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/aspect_ratio_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/audio_capture_24px.xml b/shared/src/commonMain/composeResources/drawable/audio_capture_24px.xml new file mode 100644 index 00000000000..a0a9e579530 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/audio_capture_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/autorenew_24px.xml b/shared/src/commonMain/composeResources/drawable/autorenew_24px.xml new file mode 100644 index 00000000000..70cd9d51c39 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/autorenew_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_24.xml new file mode 100644 index 00000000000..d484e18c451 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_exit_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_exit_24.xml new file mode 100644 index 00000000000..df4ebd37ae8 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_exit_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/baseline_grid_view_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_grid_view_24.xml new file mode 100644 index 00000000000..d946d1194d4 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_grid_view_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/baseline_theaters_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_theaters_24.xml new file mode 100644 index 00000000000..6f2f04c928e --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_theaters_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/benene.xml b/shared/src/commonMain/composeResources/drawable/benene.xml new file mode 100644 index 00000000000..61a861faf71 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/benene.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/build_24px.xml b/shared/src/commonMain/composeResources/drawable/build_24px.xml new file mode 100644 index 00000000000..5ffb556448e --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/build_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/chevron_left.xml b/shared/src/commonMain/composeResources/drawable/chevron_left.xml new file mode 100644 index 00000000000..45a3ee07f53 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/chevron_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/chevron_right.xml b/shared/src/commonMain/composeResources/drawable/chevron_right.xml new file mode 100644 index 00000000000..0947af840a2 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/colors_24px.xml b/shared/src/commonMain/composeResources/drawable/colors_24px.xml new file mode 100644 index 00000000000..dfc788bf9a0 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/colors_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/content_copy_24px.xml b/shared/src/commonMain/composeResources/drawable/content_copy_24px.xml new file mode 100644 index 00000000000..23673c76251 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/content_copy_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/delete_24px.xml b/shared/src/commonMain/composeResources/drawable/delete_24px.xml new file mode 100644 index 00000000000..c0b119490b3 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/delete_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/description_24px.xml b/shared/src/commonMain/composeResources/drawable/description_24px.xml new file mode 100644 index 00000000000..a71f1fc20bc --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/description_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/download_24px.xml b/shared/src/commonMain/composeResources/drawable/download_24px.xml new file mode 100644 index 00000000000..6a5a08bf3c1 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/download_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/downloading_24px.xml b/shared/src/commonMain/composeResources/drawable/downloading_24px.xml new file mode 100644 index 00000000000..de1f4faca02 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/downloading_24px.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/extension_24px.xml b/shared/src/commonMain/composeResources/drawable/extension_24px.xml new file mode 100644 index 00000000000..8d6e9b56b93 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/extension_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/face_24px.xml b/shared/src/commonMain/composeResources/drawable/face_24px.xml new file mode 100644 index 00000000000..b9153f36698 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/face_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/filter_alt_24px.xml b/shared/src/commonMain/composeResources/drawable/filter_alt_24px.xml new file mode 100644 index 00000000000..5931b6958b9 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/filter_alt_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/folder_open_24px.xml b/shared/src/commonMain/composeResources/drawable/folder_open_24px.xml new file mode 100644 index 00000000000..06e0e968fcb --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/folder_open_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/format_paint_24px.xml b/shared/src/commonMain/composeResources/drawable/format_paint_24px.xml new file mode 100644 index 00000000000..396c61a8b7a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/format_paint_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/history_toggle_off_24px.xml b/shared/src/commonMain/composeResources/drawable/history_toggle_off_24px.xml new file mode 100644 index 00000000000..18a2eaba156 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/history_toggle_off_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/home_icon_filled_24.xml b/shared/src/commonMain/composeResources/drawable/home_icon_filled_24.xml new file mode 100644 index 00000000000..303b6a10c05 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/home_icon_filled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/home_icon_outline_24.xml b/shared/src/commonMain/composeResources/drawable/home_icon_outline_24.xml new file mode 100644 index 00000000000..2d5f93a674d --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/home_icon_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_add_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_add_24.xml new file mode 100644 index 00000000000..200a4de6cf3 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_add_24.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_audiotrack_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_audiotrack_24.xml new file mode 100644 index 00000000000..6200ba23395 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_audiotrack_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_discord_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_discord_24.xml new file mode 100644 index 00000000000..19b153c43fb --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_discord_24.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_hd_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_hd_24.xml new file mode 100644 index 00000000000..da7d73ace4d --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_hd_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_replay_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_replay_24.xml new file mode 100644 index 00000000000..e247aa9243a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_replay_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_speed_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_speed_24.xml new file mode 100644 index 00000000000..94177781988 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_speed_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_storage_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_storage_24.xml new file mode 100644 index 00000000000..27bb2f79569 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_storage_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_subtitles_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_subtitles_24.xml new file mode 100644 index 00000000000..c5bfd344f81 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_subtitles_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_system_update_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_system_update_24.xml new file mode 100644 index 00000000000..a2972396f3b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_system_update_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_mute_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_mute_24.xml new file mode 100644 index 00000000000..3ab92ed430b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_mute_24.xml @@ -0,0 +1,4 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_up_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_up_24.xml new file mode 100644 index 00000000000..7640312dd84 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_up_24.xml @@ -0,0 +1,4 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_github_logo.xml b/shared/src/commonMain/composeResources/drawable/ic_github_logo.xml new file mode 100644 index 00000000000..27d704b59dd --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_github_logo.xml @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/keyboard_double_arrow_right_24px.xml b/shared/src/commonMain/composeResources/drawable/keyboard_double_arrow_right_24px.xml new file mode 100644 index 00000000000..fcb76975a0a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/keyboard_double_arrow_right_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/kitsu_icon.xml b/shared/src/commonMain/composeResources/drawable/kitsu_icon.xml new file mode 100644 index 00000000000..25811873422 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/kitsu_icon.xml @@ -0,0 +1,15 @@ + + + + diff --git a/shared/src/commonMain/composeResources/drawable/library_icon.xml b/shared/src/commonMain/composeResources/drawable/library_icon.xml new file mode 100644 index 00000000000..b67c2e51d26 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/library_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/library_icon_filled.xml b/shared/src/commonMain/composeResources/drawable/library_icon_filled.xml new file mode 100644 index 00000000000..30a40902ec4 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/library_icon_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/metadata_overlay_icon.xml b/shared/src/commonMain/composeResources/drawable/metadata_overlay_icon.xml new file mode 100644 index 00000000000..818d408c8d7 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/movie_edit_24px.xml b/shared/src/commonMain/composeResources/drawable/movie_edit_24px.xml new file mode 100644 index 00000000000..f76f999e506 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/movie_edit_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/open_in_new_24px.xml b/shared/src/commonMain/composeResources/drawable/open_in_new_24px.xml new file mode 100644 index 00000000000..4243969a791 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/open_in_new_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/palette_24px.xml b/shared/src/commonMain/composeResources/drawable/palette_24px.xml new file mode 100644 index 00000000000..712cfd1fd0d --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/palette_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/pause_24px.xml b/shared/src/commonMain/composeResources/drawable/pause_24px.xml new file mode 100644 index 00000000000..5bb9281eb20 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/pause_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/person_24px.xml b/shared/src/commonMain/composeResources/drawable/person_24px.xml new file mode 100644 index 00000000000..2a5d2bebfd2 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/person_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/picture_in_picture_center_24px.xml b/shared/src/commonMain/composeResources/drawable/picture_in_picture_center_24px.xml new file mode 100644 index 00000000000..9a0c919363c --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/picture_in_picture_center_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/play_arrow_24px.xml b/shared/src/commonMain/composeResources/drawable/play_arrow_24px.xml new file mode 100644 index 00000000000..322be00eca6 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/play_arrow_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/plugin_lang.xml b/shared/src/commonMain/composeResources/drawable/plugin_lang.xml new file mode 100644 index 00000000000..ac33489ac11 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/plugin_lang.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/quick_novel_icon.xml b/shared/src/commonMain/composeResources/drawable/quick_novel_icon.xml new file mode 100644 index 00000000000..8f0b3cf3e7b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/quick_novel_icon.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/search_icon.xml b/shared/src/commonMain/composeResources/drawable/search_icon.xml new file mode 100644 index 00000000000..41ca1103de3 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/search_icon.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/settings_icon_filled.xml b/shared/src/commonMain/composeResources/drawable/settings_icon_filled.xml new file mode 100644 index 00000000000..1d31bb7d024 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/settings_icon_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/settings_icon_outline.xml b/shared/src/commonMain/composeResources/drawable/settings_icon_outline.xml new file mode 100644 index 00000000000..bdc9e98d38b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/settings_icon_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/shuffle_24px.xml b/shared/src/commonMain/composeResources/drawable/shuffle_24px.xml new file mode 100644 index 00000000000..bed4f19ba7f --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/shuffle_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/skip_next_24px.xml b/shared/src/commonMain/composeResources/drawable/skip_next_24px.xml new file mode 100644 index 00000000000..4cb20d68f3e --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/skip_next_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/title_24px.xml b/shared/src/commonMain/composeResources/drawable/title_24px.xml new file mode 100644 index 00000000000..77f74466d38 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/title_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/tooltip_24px.xml b/shared/src/commonMain/composeResources/drawable/tooltip_24px.xml new file mode 100644 index 00000000000..02f77939ffa --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/tooltip_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index e5eca5dd287..7f65e0471b0 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -11,4 +11,128 @@ Navigate up Lorem ipsum Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - \ No newline at end of file + + + Advanced Search + Gives you the search results separated by provider + All Languages + Anime + Dub + Sub + App theme + Asian Drama + Audio Book + Audio + Autoplay next episode + Start the next episode when the current one ends + Give a benene to the devs + %1$d Benenes given to devs + No Benenes given + Poster title location + Put the title under the poster + Cartoon + General + Player + Providers + Layout + CloudStream Wiki + Media + Join Discord + Display Dubbed/Subbed Anime + Documentary + Playback speed + Show skip popups for opening/ending + Update watch progress + Automatically sync your current episode progress + Github + Show posters from Kitsu + Library + Light novel app by the same devs + Livestream + Movie + Music + NSFW + Video + OVA + Player resize button + Remove the black borders + Podcast + Poster size + Changes size of posters + Toggle UI elements on poster + Links + Looks + Player features + Features + Hide selected video quality in search results + Preferred media + Seekbar preview + Enable preview thumbnail on seekbar + Primary color + Extension languages + 4K + Blu-ray + Cam + CamHD + CamRip + DVD + HD + HDR + HQ + SD + SDR + TC + TS + UHD + Web + WP + Random Button + Show random button on Homepage and Library + Poster + Search Suggestions + Show search suggestions while typing + Show cast panel + Dub label + Episode Text + Show filler episode for anime + Quality label + Show Player Metadata Overlay + Rating Label + Sub label + Title + Show trailers + Adds a speed option in the player + Start videos paused + Reduce stress when selecting sources + Home + Search + Settings + Torrent + Series + Video + Skip OP + Any legal issues regarding the content on this application should be taken up with the actual file hosts and providers themselves as we are not affiliated with them.\n\nIn case of copyright infringement, please directly contact the responsible parties or the streaming websites.\n\nThe app is purely for educational and personal use.\n\nCloudStream does not host any content on the app, and has no control over what media is put up or taken down. CloudStream functions like any other search engine, such as Google. CloudStream does not host, upload or manage any videos, films or content. It simply crawls, aggregates and displays links in a convenient, user-friendly interface.\n\nIt merely scrapes 3rd-party websites that are publicly accessible via any regular web browser. It is the responsibility of user to avoid any actions that might violate the laws governing his/her locality. Use CloudStream at your own risk. + Subtitle Settings + Text Color + Outline Color + Background Color + Edge Type + Subtitle Elevation + Font + Font Size + Auto-Select Language + Normal + Outline + Depressed + Shadow + Raised + The quick brown fox jumps over the lazy dog + Reset + Make all subtitles bold + Make all subtitles italic + DNS over HTTPS + Useful for bypassing ISP blocks + Downloads + There are currently no downloads. + Download path + diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index 168b8259028..91c9f4c1a14 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -63,7 +63,8 @@ class UIPreferences(preferences: PreferenceStore) { "primary_color_key", "Normal" ) val theme = preferences.getString( - "app_theme_key", "AmoledLight" + // The desktop's Prime Video dark, see desktopThemeMode + "app_theme_key", "Black" ) val layout = preferences.getInt( "app_layout_key", -1 @@ -152,6 +153,11 @@ class ProviderPreferences(preferences: PreferenceStore) { val displayDubSub = preferences.getStringSet( "display_sub_key", defaultDub ) + + /** The provider shown on the home screen, by name. Android keeps this per account in its DataStore */ + val homeProvider = preferences.getString( + "home_api_used", "" + ) } class PlayerPreferences(preferences: PreferenceStore) { diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt index 6a363909f59..2bd11a32ba4 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt @@ -14,11 +14,12 @@ object Colors { disabledContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f) ) + // The main action in the theme's accent, as theme.WhiteButton internal val whiteButton @Composable @ReadOnlyComposable get() = ButtonColors( - containerColor = MaterialTheme.colorScheme.onBackground, - contentColor = MaterialTheme.colorScheme.surfaceVariant, - disabledContainerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), - disabledContentColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f) + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), + disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f) ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt new file mode 100644 index 00000000000..e87cc3039bd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt @@ -0,0 +1,278 @@ +package com.lagradost.cloudstream4.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.library.InMemoryWatchStore +import com.lagradost.cloudstream4.library.PlaybackPosition +import com.lagradost.cloudstream4.library.PlaybackTracking +import com.lagradost.cloudstream4.library.SavedSource +import com.lagradost.cloudstream4.library.TitleHeader +import com.lagradost.cloudstream4.library.WatchIds +import com.lagradost.cloudstream4.library.WatchStore +import com.lagradost.cloudstream4.library.WatchType +import com.lagradost.cloudstream4.providers.loadSafely +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +/** What the player needs to find and play the links of one movie or episode */ +data class PlayRequest( + val apiName: String, + val title: String, + /** For example "S1 E3 · Pilot", null for a movie */ + val episodeLabel: String?, + val data: String, + /** Where to save progress, null when nothing is tracked */ + val tracking: PlaybackTracking? = null, + /** A downloaded file to play instead of finding links */ + val localFile: String? = null, + /** Subtitles saved beside [localFile], their url a file path */ + val localSubtitles: List = emptyList(), + /** The source the title was last watched from, played as soon as it is found */ + val preferredSource: SavedSource? = null, +) + +/** Where Continue watching left off: an episode, or the movie when [episode] is null */ +data class ResumeTarget( + val episode: Episode?, + /** For example "S1 E3", null for a movie */ + val label: String?, + val positionMs: Long, +) + +/** One season of episodes, or all episodes when the provider has no seasons */ +data class SeasonGroup( + val season: Int?, + val label: String, + val episodes: List, +) + +sealed interface DetailStatus { + data object Loading : DetailStatus + data class Failed(val message: String) : DetailStatus + data object Done : DetailStatus +} + +data class DetailState( + val status: DetailStatus = DetailStatus.Loading, + val response: LoadResponse? = null, + /** Set for movies and live streams, played directly */ + val movieData: String? = null, + /** Dubbed, subbed... when the provider splits episodes that way, otherwise empty */ + val dubs: List = emptyList(), + val selectedDub: DubStatus? = null, + val seasons: List = emptyList(), + val selectedSeason: Int = 0, + /** Why this item cannot be played on desktop, for example a torrent */ + val unsupported: String? = null, + /** The library list the title is in */ + val bookmark: WatchType = WatchType.NONE, + /** How far each episode, or the movie by the title's id, was watched, by its id */ + val progress: Map = emptyMap(), + /** The ids the Android app gives the episodes */ + val episodeIds: Map = emptyMap(), + val titleId: Int? = null, + val resume: ResumeTarget? = null, +) { + fun progressOf(episode: Episode): PlaybackPosition? = episodeIds[episode]?.let { progress[it] } +} + +sealed interface DetailAction { + data class SelectSeason(val index: Int) : DetailAction + data class SelectDub(val dub: DubStatus) : DetailAction + /** Puts the title in a library list, or takes it out with [WatchType.NONE] */ + data class SetBookmark(val type: WatchType) : DetailAction + data object Retry : DetailAction +} + +/** The details page of one item: its info, and either a play button or its seasons and episodes */ +class DetailViewModel( + private val api: MainAPI, + private val url: String, + private val store: WatchStore = InMemoryWatchStore(), + /** Torrent titles can play */ + private val torrents: Boolean = false, +) : ViewModel(), StateContainer by DefaultStateContainer(DetailState()), + ActionHandler { + + private var loadJob: Job? = null + /** Episodes per dub, or under null when the provider does not split them */ + private var episodesByDub: Map> = emptyMap() + + init { + load() + // Progress saved by the player shows when the user comes back + viewModelScope.launch { store.version.drop(1).collect { updateState { withWatchData(this) } } } + } + + override fun onAction(action: DetailAction) { + when (action) { + is DetailAction.SelectSeason -> updateState { + copy(selectedSeason = action.index.coerceIn(0, (seasons.size - 1).coerceAtLeast(0))) + } + + is DetailAction.SelectDub -> { + val response = state.value.response ?: return + if (action.dub !in episodesByDub) return + updateState { + copy(selectedDub = action.dub, seasons = groupSeasons(response, episodesByDub[action.dub].orEmpty()), selectedSeason = 0) + } + } + + is DetailAction.SetBookmark -> { + val header = header() ?: return + store.setBookmark(header, action.type) + updateState { copy(bookmark = action.type) } + } + + DetailAction.Retry -> load() + } + } + + private fun header(): TitleHeader? { + val response = state.value.response ?: return null + val id = state.value.titleId ?: return null + return TitleHeader(id, api.name, response.url, response.name, response.type, response.posterUrl, response.posterHeaders, response.year, response.plot) + } + + /** The library list, progress and where to resume, read again from the store */ + private fun withWatchData(state: DetailState): DetailState { + val id = state.titleId ?: return state + val ids = state.episodeIds.values + id + val progress = ids.mapNotNull { episodeId -> store.position(episodeId)?.let { episodeId to it } }.toMap() + val resume = store.resume(id)?.let { entry -> + val episode = entry.episodeId?.let { episodeId -> state.episodeIds.entries.firstOrNull { it.value == episodeId }?.key } + val position = progress[entry.episodeId ?: id]?.takeUnless { it.watched }?.positionMs ?: 0 + when { + episode != null -> ResumeTarget(episode, episodeNumber(episode), position) + state.movieData != null && position > 0 -> ResumeTarget(null, null, position) + else -> null + } + } + return state.copy(bookmark = store.bookmark(id)?.status ?: WatchType.NONE, progress = progress, resume = resume) + } + + private fun load() { + loadJob?.cancel() + updateState { DetailState() } + loadJob = viewModelScope.launch { + when (val result = api.loadSafely(url)) { + is Resource.Success -> show(result.value) + is Resource.Failure -> updateState { copy(status = DetailStatus.Failed(result.errorString)) } + is Resource.Loading -> Unit + } + } + } + + private fun show(response: LoadResponse) { + episodesByDub = when (response) { + is TvSeriesLoadResponse -> mapOf(null to response.episodes) + is AnimeLoadResponse -> response.episodes.filterValues { it.isNotEmpty() } + else -> emptyMap() + } + val dubs = episodesByDub.keys.filterNotNull() + val dub = dubs.firstOrNull() + val titleId = WatchIds.titleId(response, api) + updateState { + withWatchData(DetailState( + status = DetailStatus.Done, + response = response, + movieData = when (response) { + is MovieLoadResponse -> response.dataUrl + is LiveStreamLoadResponse -> response.dataUrl + is TorrentLoadResponse -> if (torrents) (response.torrent ?: response.magnet)?.takeIf { it.isNotBlank() } else null + else -> null + }, + dubs = dubs, + selectedDub = dub, + seasons = groupSeasons(response, episodesByDub[dub].orEmpty()), + unsupported = if (response is TorrentLoadResponse && !torrents) "Torrents cannot be played on desktop yet." else null, + titleId = titleId, + episodeIds = WatchIds.episodeIds(response, titleId), + )) + } + } + + /** + * What to play for an episode, or for the movie when [episode] is null. It starts where it was + * left unless it was finished, and its progress is saved with the next episode to move on to. + */ + fun playRequest(episode: Episode? = null, fromStart: Boolean = false): PlayRequest? { + val current = state.value + val response = current.response ?: return null + val data = episode?.data ?: current.movieData ?: return null + val header = header() + val id = if (episode == null) current.titleId else current.episodeIds[episode] + val tracking = if (header != null && id != null) { + val ordered = current.seasons.flatMap { it.episodes } + val next = episode?.let { ordered.getOrNull(ordered.indexOf(it) + 1) } + PlaybackTracking( + header = header, + id = id, + episode = episode?.episode, + season = episode?.season, + next = next?.let { n -> current.episodeIds[n]?.let { PlaybackTracking.NextEpisode(it, n.episode, n.season) } }, + startPositionMs = if (fromStart) 0 else current.progress[id]?.takeUnless { it.watched }?.positionMs ?: 0, + ) + } else null + val source = current.titleId?.let(store::lastSource) + return PlayRequest(api.name, response.name, episode?.let(::episodeLabel), data, tracking, preferredSource = source) + } + + /** + * What Continue watching plays: the episode the title was left at, or the movie, from where it + * stopped. Null when there is nothing to pick up, for example an episode the provider dropped. + */ + fun resumeRequest(): PlayRequest? { + val current = state.value + val resume = current.resume + return when { + resume?.episode != null -> playRequest(resume.episode) + current.movieData != null -> playRequest() + else -> null + } + } + + companion object { + /** Groups episodes by season in season order, keeping the provider's order inside a season */ + fun groupSeasons(response: LoadResponse, episodes: List): List { + if (episodes.isEmpty()) return emptyList() + val names = (response as? EpisodeResponse)?.seasonNames.orEmpty() + val bySeason = episodes.groupBy { it.season } + if (bySeason.keys == setOf(null)) return listOf(SeasonGroup(null, "Episodes", episodes)) + return bySeason.entries + .sortedBy { it.key ?: Int.MAX_VALUE } + .map { (season, list) -> + val data = names.firstOrNull { it.season == season } + val label = if (season == null) "Other" else data?.name ?: "Season ${data?.displaySeason ?: season}" + SeasonGroup(season, label, list) + } + } + + /** For example "S1 E3", or "Episode" without numbers */ + fun episodeNumber(episode: Episode): String = + listOfNotNull(episode.season?.let { "S$it" }, episode.episode?.let { "E$it" }).joinToString(" ").ifEmpty { "Episode" } + + /** For example "S1 E3 · Pilot" */ + fun episodeLabel(episode: Episode): String { + val number = listOfNotNull(episode.season?.let { "S$it" }, episode.episode?.let { "E$it" }).joinToString(" ") + return listOf(number, episode.name.orEmpty().trim()).filter { it.isNotEmpty() }.joinToString(" · ") + .ifEmpty { "Episode" } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/home/HomeViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/home/HomeViewModel.kt new file mode 100644 index 00000000000..eac5f843575 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/home/HomeViewModel.kt @@ -0,0 +1,127 @@ +package com.lagradost.cloudstream4.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.providers.filterProviders +import com.lagradost.cloudstream4.providers.mainPageSafely +import com.lagradost.cloudstream4.providers.withoutQualities +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** One row on the home screen, a section of the provider's home page */ +data class HomeRow( + val name: String, + val items: List, + val horizontalImages: Boolean, +) + +sealed interface HomeStatus { + /** No provider with a home page is installed or enabled */ + data object NoProviders : HomeStatus + data object Loading : HomeStatus + data object Done : HomeStatus + data class Failed(val message: String) : HomeStatus +} + +data class HomeState( + /** Names of the providers that have a home page and pass the provider settings */ + val providers: List = emptyList(), + val selected: String? = null, + val rows: List = emptyList(), + val status: HomeStatus = HomeStatus.NoProviders, +) + +sealed interface HomeAction { + data class SelectProvider(val name: String) : HomeAction + data object Reload : HomeAction + /** Plugins were loaded or removed, or the provider settings changed */ + data object ProvidersChanged : HomeAction +} + +/** + * Shows the home page of one provider. The chosen provider is saved, and if it goes away + * (uninstalled, or hidden by the provider settings) the first remaining one is shown instead. + * + * @param apis all loaded providers, filtered here by the provider settings + */ +class HomeViewModel( + private val apis: () -> List, + private val settings: AppSettings, +) : ViewModel(), StateContainer by DefaultStateContainer(HomeState()), + ActionHandler { + + private var available: List = emptyList() + private var loadJob: Job? = null + + init { + refreshProviders() + } + + override fun onAction(action: HomeAction) { + when (action) { + is HomeAction.SelectProvider -> { + val api = available.firstOrNull { it.name == action.name } ?: return + settings.provider.homeProvider.set(api.name) + load(api) + } + + HomeAction.Reload -> available.firstOrNull { it.name == state.value.selected }?.let(::load) + HomeAction.ProvidersChanged -> refreshProviders() + } + } + + private fun refreshProviders() { + val provider = settings.provider + available = filterProviders( + apis(), + languages = provider.extensionLanguages.get(), + preferredMedia = provider.preferredMedia.get(), + requireMainPage = true, + ) + val names = available.map { it.name } + updateState { copy(providers = names) } + + val saved = provider.homeProvider.get() + val api = available.firstOrNull { it.name == saved } ?: available.firstOrNull() + when { + api == null -> { + loadJob?.cancel() + updateState { copy(selected = null, rows = emptyList(), status = HomeStatus.NoProviders) } + } + // Keep what is on screen if the shown provider is still there + api.name == state.value.selected && state.value.status != HomeStatus.NoProviders -> Unit + else -> load(api) + } + } + + private fun load(api: MainAPI) { + loadJob?.cancel() + updateState { copy(selected = api.name, rows = emptyList(), status = HomeStatus.Loading) } + loadJob = viewModelScope.launch { + val result = api.mainPageSafely(page = 1) + // A different provider was picked while this one was loading + if (state.value.selected != api.name) return@launch + val hidden = settings.ui.filterQuality.get() + updateState { + when (result) { + is Resource.Success -> copy( + rows = result.value.filterNotNull().flatMap { it.items } + .map { HomeRow(it.name, it.list.withoutQualities(hidden), it.isHorizontalImages) } + .filter { it.items.isNotEmpty() }, + status = HomeStatus.Done, + ) + + is Resource.Failure -> copy(status = HomeStatus.Failed(result.errorString)) + is Resource.Loading -> this + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt new file mode 100644 index 00000000000..702751d2421 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt @@ -0,0 +1,202 @@ +package com.lagradost.cloudstream4.library + +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** The Android app's library lists, with the same ids it stores */ +enum class WatchType(val internalId: Int, val label: String) { + WATCHING(0, "Watching"), + COMPLETED(1, "Completed"), + ONHOLD(2, "On hold"), + DROPPED(3, "Dropped"), + PLANTOWATCH(4, "Plan to watch"), + NONE(5, "None"); + + companion object { + /** The lists in the order the library shows them */ + val lists = listOf(WATCHING, PLANTOWATCH, ONHOLD, COMPLETED, DROPPED) + + fun fromInternalId(id: Int?) = entries.find { it.internalId == id } ?: NONE + } +} + +/** What is needed to show a title again without loading it: in the library, and in Continue watching */ +data class TitleHeader( + val id: Int, + val apiName: String, + val url: String, + val name: String, + val type: TvType?, + val posterUrl: String?, + val posterHeaders: Map? = null, + val year: Int? = null, + val plot: String? = null, +) + +data class Bookmark( + val header: TitleHeader, + val status: WatchType, + val bookmarkedTime: Long, + val latestUpdatedTime: Long, +) + +data class PlaybackPosition(val positionMs: Long, val durationMs: Long) { + val fraction: Float get() = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) + + /** Like the Android app, past 90% counts as watched */ + val watched: Boolean get() = durationMs > 0 && positionMs * 100 / durationMs >= NEXT_EPISODE_PERCENTAGE +} + +/** Where to pick up a title: the episode last watched, or the next one once that was finished */ +data class ResumeEntry( + val header: TitleHeader, + val episodeId: Int?, + val episode: Int?, + val season: Int?, + val updateTime: Long, +) + +/** The source a title was last watched from, picked first when it plays again: its name and quality */ +data class SavedSource(val name: String, val quality: Int) + +/** The library, playback positions and Continue watching, stored the way the Android app stores them */ +interface WatchStore { + /** Changes whenever anything is saved, so screens know to read again */ + val version: StateFlow + + fun bookmark(id: Int): Bookmark? + fun bookmarks(): List + fun setBookmark(header: TitleHeader, status: WatchType) + + fun position(id: Int): PlaybackPosition? + fun setPosition(id: Int, positionMs: Long, durationMs: Long) + + fun resume(parentId: Int): ResumeEntry? + fun resumeEntries(): List + fun setResume(entry: ResumeEntry) + fun removeResume(parentId: Int) + + /** The source the title, by its id, last played from */ + fun lastSource(parentId: Int): SavedSource? + fun setLastSource(parentId: Int, source: SavedSource) +} + +/** Kept in memory only, for tests and previews */ +class InMemoryWatchStore(private val clock: () -> Long = { System.currentTimeMillis() }) : WatchStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow = _version.asStateFlow() + private val bookmarks = LinkedHashMap() + private val positions = HashMap() + private val resumes = LinkedHashMap() + private val sources = HashMap() + + private fun changed() = _version.update { it + 1 } + + override fun bookmark(id: Int) = bookmarks[id] + override fun bookmarks() = bookmarks.values.toList() + override fun setBookmark(header: TitleHeader, status: WatchType) { + val now = clock() + if (status == WatchType.NONE) bookmarks.remove(header.id) + else bookmarks[header.id] = Bookmark(header, status, bookmarks[header.id]?.bookmarkedTime ?: now, now) + changed() + } + + override fun position(id: Int) = positions[id] + override fun setPosition(id: Int, positionMs: Long, durationMs: Long) { + if (durationMs < MIN_DURATION_MS) return + positions[id] = PlaybackPosition(positionMs, durationMs) + changed() + } + + override fun resume(parentId: Int) = resumes[parentId] + override fun resumeEntries() = resumes.values.toList() + override fun setResume(entry: ResumeEntry) { + resumes[entry.header.id] = entry + changed() + } + + override fun removeResume(parentId: Int) { + resumes.remove(parentId) + changed() + } + + override fun lastSource(parentId: Int) = sources[parentId] + override fun setLastSource(parentId: Int, source: SavedSource) { + sources[parentId] = source + } +} + +/** Shorter videos are not remembered, like on Android */ +const val MIN_DURATION_MS = 30_000L + +/** From this far into an episode, Continue watching moves on to the next one */ +const val NEXT_EPISODE_PERCENTAGE = 90 + +/** The ids the Android app gives titles and episodes, so saved positions and lists mean the same on both */ +object WatchIds { + fun titleId(response: LoadResponse, api: MainAPI): Int = + response.uniqueUrl.replace(api.mainUrl, "").replace("/", "").hashCode() + + /** + * Every episode's id, worked out like ResultViewModel2: anime by dub, number and season, series by + * season and number. A movie has the title's own id. + */ + fun episodeIds(response: LoadResponse, titleId: Int): Map = when (response) { + is AnimeLoadResponse -> buildMap { + for ((dub, episodes) in response.episodes) for ((index, episode) in episodes.withIndex()) { + val number = episode.episode ?: (index + 1) + putIfAbsent(episode, titleId + number + dubIndex(dub) * 1_000_000 + (episode.season?.times(10_000) ?: 0)) + } + } + is TvSeriesLoadResponse -> buildMap { + val sorted = response.episodes.sortedBy { (it.season?.times(10_000) ?: 0) + (it.episode ?: 0) } + for ((index, episode) in sorted.withIndex()) { + val number = episode.episode ?: (index + 1) + putIfAbsent(episode, titleId + (episode.season?.times(100_000) ?: 0) + number + 1) + } + } + else -> emptyMap() + } + + private fun dubIndex(dub: DubStatus) = dub.id +} + +/** What the player needs to save progress for one movie or episode */ +data class PlaybackTracking( + val header: TitleHeader, + /** The episode's id, or the title's for a movie */ + val id: Int, + val episode: Int? = null, + val season: Int? = null, + /** Continue watching moves here when this one is finished, null for a movie or the last episode */ + val next: NextEpisode? = null, + val startPositionMs: Long = 0, +) { + data class NextEpisode(val id: Int, val episode: Int?, val season: Int?) +} + +/** + * Saves how far the user got, like the Android app's setViewPosAndResume: the position of what is + * playing, and Continue watching pointing at it, or at the next episode once it is nearly done. + * Finishing the last episode or a movie takes the title out of Continue watching. + */ +fun WatchStore.saveProgress(tracking: PlaybackTracking, positionMs: Long, durationMs: Long, now: Long = System.currentTimeMillis()) { + if (durationMs < MIN_DURATION_MS || positionMs <= 0) return + setPosition(tracking.id, positionMs, durationMs) + val finished = positionMs * 100 / durationMs >= NEXT_EPISODE_PERCENTAGE + val parentId = tracking.header.id + when { + !finished -> setResume(ResumeEntry(tracking.header, tracking.id, tracking.episode, tracking.season, now)) + tracking.next != null -> setResume(ResumeEntry(tracking.header, tracking.next.id, tracking.next.episode, tracking.next.season, now)) + else -> removeResume(parentId) + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/HumanChecks.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/HumanChecks.kt new file mode 100644 index 00000000000..acefbabe446 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/HumanChecks.kt @@ -0,0 +1,15 @@ +package com.lagradost.cloudstream4.player + +import kotlinx.coroutines.flow.Flow + +/** + * Sites whose links wait on a check only a person can pass, such as Cloudflare's tick box. Their links + * come last: the user is only asked to pass the checks once nothing else is left to play. + */ +interface HumanChecks { + /** A site's host, each time one asks for a check that did not pass by itself */ + val asked: Flow + + /** Lets the user pass the site's check, in a window. True once passed */ + suspend fun pass(host: String): Boolean +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt new file mode 100644 index 00000000000..fe5a578bdf5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt @@ -0,0 +1,220 @@ +package com.lagradost.cloudstream4.player + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.providers.loadLinksSafely +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +data class LinksState( + /** Playable links, best quality first */ + val links: List = emptyList(), + val subtitles: List = emptyList(), + val loading: Boolean = true, + /** The url of the link being played */ + val selected: String? = null, + /** Links that failed to play, by url, with the reason */ + val failed: Map = emptyMap(), + /** Links found that the player cannot use, such as DRM, or torrents where they cannot play */ + val skipped: Int = 0, + /** + * Sites whose links wait on a check only a person can pass. They come last: the user is offered + * to pass them once nothing else is left to play + */ + val checkSites: List = emptyList(), + /** The user is passing those checks */ + val checking: Boolean = false, +) { + val selectedLink: ExtractorLink? get() = links.firstOrNull { it.url == selected } + + /** Loading finished and nothing is left to try */ + val exhausted: Boolean get() = !loading && links.none { it.url !in failed } +} + +sealed interface LinksAction { + data class Select(val url: String) : LinksAction + /** The player could not play this link, so the next one is tried */ + data class Failed(val url: String, val reason: String) : LinksAction + data object Retry : LinksAction + /** Stop finding links: the best found so far plays, and links still coming are left out */ + data object Skip : LinksAction + /** Pass the checks of [LinksState.checkSites], then look for links again */ + data object PassChecks : LinksAction +} + +/** + * Finds the links of one movie or episode and picks which to play: the best one once all links are + * in, or the best so far when links keep coming after [autoPickAfter]. A link that fails to play + * moves on to the next best one. The source the title was last watched from plays as soon as it + * comes, and is tried before the others. + */ +class LinksViewModel( + /** Null when only a downloaded file plays and its extension is not loaded */ + private val api: MainAPI?, + val request: PlayRequest, + private val autoPickAfter: Duration = 6.seconds, + /** Torrents and magnet links can play, after every other link */ + private val torrents: Boolean = false, + /** Checks sites can ask for, which the user can pass. Without, such sites give no links */ + private val checks: HumanChecks? = null, +) : ViewModel(), StateContainer by DefaultStateContainer(LinksState()), ActionHandler { + + private var loadJob: Job? = null + /** Set once a link was picked automatically: from then on a new link plays if nothing else is */ + @kotlin.concurrent.Volatile + private var autoPicking = false + + init { + load() + } + + override fun onAction(action: LinksAction) { + when (action) { + is LinksAction.Select -> updateState { copy(selected = action.url) } + is LinksAction.Failed -> updateState { + val failed = failed + (action.url to action.reason) + // Only move on if the failed link is still the one playing + copy(failed = failed, selected = if (selected == action.url) nextLink(links, failed) else selected) + } + + LinksAction.Retry -> load() + LinksAction.PassChecks -> passChecks() + LinksAction.Skip -> skip() + } + } + + private fun skip() { + if (!state.value.loading || state.value.links.isEmpty()) return + loadJob?.cancel() + autoPicking = true + updateState { copy(loading = false, selected = selected ?: nextLink(links, failed)) } + } + + private fun passChecks() { + val checks = checks ?: return + if (state.value.checking) return + val sites = state.value.checkSites + updateState { copy(checking = true) } + viewModelScope.launch { + // One window after another, each closing once its check is passed + val passed = sites.filter { checks.pass(it) } + updateState { copy(checking = false) } + // Their links come now. What failed already is not tried again + if (passed.isNotEmpty()) load(keepFailed = true) + } + } + + private fun load(keepFailed: Boolean = false) { + loadJob?.cancel() + autoPicking = false + val local = request.localFile + if (local != null) { + // A download plays at once, with the subtitles saved beside it + @Suppress("DEPRECATION") + val link = ExtractorLink(LOCAL_SOURCE, LOCAL_SOURCE, local, "", Qualities.Unknown.value, type = ExtractorLinkType.VIDEO) + updateState { LinksState(links = listOf(link), subtitles = request.localSubtitles, loading = false, selected = local) } + return + } + if (api == null) { + updateState { LinksState(loading = false) } + return + } + updateState { LinksState(failed = if (keepFailed) failed else emptyMap()) } + loadJob = viewModelScope.launch { + // Subscribed before the extension runs, so no site is missed + val watcher = checks?.let { checks -> + launch(start = CoroutineStart.UNDISPATCHED) { + checks.asked.collect { site -> updateState { if (site in checkSites) this else copy(checkSites = checkSites + site) } } + } + } + val picker = launch { + // Some extractors take minutes, so do not wait for all of them once a link is in + state.first { it.links.isNotEmpty() } + delay(autoPickAfter) + pickIfNone() + } + // A torrent title's data is its magnet or .torrent link, which the extension may not repeat + if (torrents) torrentLink(request.data)?.let(::addLink) + api.loadLinksSafely( + request.data, + onSubtitle = { sub -> updateState { if (subtitles.any { it.url == sub.url }) this else copy(subtitles = subtitles + sub) } }, + // An extractor can still call back after a skip, from its own thread + onLink = { if (isActive) addLink(it) }, + ) + picker.cancel() + watcher?.cancel() + // One update, so nobody sees loading finished with nothing picked yet + autoPicking = true + updateState { copy(loading = false, selected = selected ?: nextLink(links, failed)) } + } + } + + private fun addLink(link: ExtractorLink) { + val playable = link !is DrmExtractorLink && (torrents || !isTorrent(link)) + updateState { + when { + !playable -> copy(skipped = skipped + 1) + links.any { it.url == link.url } -> this + else -> { + // Torrents last: they take a while to start and share the user's connection + val links = (links + link).sortedWith(compareBy { isTorrent(it) }.thenByDescending { rank(it.quality) }) + val preferred = request.preferredSource?.takeIf { it.name == link.name && it.quality == link.quality && link.url !in failed } + copy(links = links, selected = selected ?: preferred?.let { link.url } ?: if (autoPicking) nextLink(links, failed) else null) + } + } + } + } + + /** The best link not failed yet, one from the source last watched first */ + private fun nextLink(links: List, failed: Map): String? { + val left = links.filter { it.url !in failed } + return (left.firstOrNull { it.name == request.preferredSource?.name } ?: left.firstOrNull())?.url + } + + private fun pickIfNone() { + autoPicking = true + updateState { if (selected != null) this else copy(selected = nextLink(links, failed)) } + } + + companion object { + /** The source name of a downloaded file */ + const val LOCAL_SOURCE = "Downloaded" + + + fun isTorrent(link: ExtractorLink) = link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.MAGNET + + /** The link a torrent title's data is, as the Android app plays it */ + private fun torrentLink(data: String): ExtractorLink? { + val type = when { + data.startsWith("magnet:") -> ExtractorLinkType.MAGNET + data.substringBefore('?').endsWith(".torrent") -> ExtractorLinkType.TORRENT + else -> return null + } + @Suppress("DEPRECATION") + return ExtractorLink("Torrent", "Torrent", data, "", Qualities.Unknown.value, type = type) + } + + /** Higher is better. An unknown quality ranks with 480p, as on Android */ + fun rank(quality: Int): Int = if (quality == Qualities.Unknown.value) Qualities.P480.value else quality + + /** For example "Vidplay · 1080p" */ + fun label(link: ExtractorLink): String = + listOf(link.name, Qualities.getStringByInt(link.quality)).filter { it.isNotBlank() }.distinct().joinToString(" · ") + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/SubtitleSettings.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/SubtitleSettings.kt new file mode 100644 index 00000000000..f26336dc2df --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/SubtitleSettings.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.utils.SubtitleHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.math.roundToInt + +/** + * How subtitles look: the Android app's SaveCaptionStyle, field for field, so both apps read the + * same setting. Colors are ARGB, sizes in Android's units: text in sp, edge in px, elevation in dp. + * The text changes (upper case, removing captions and bloat) are kept for Android but not applied here. + */ +data class SubtitleStyle( + val foregroundColor: Int = WHITE, + val backgroundColor: Int = TRANSPARENT, + val windowColor: Int = TRANSPARENT, + val edgeType: Int = EDGE_OUTLINE, + val edgeColor: Int = BLACK, + /** The Android app's SubtitleFont name, for example "Verdana" or "TimesNewRoman" */ + val font: String? = null, + val typefaceFilePath: String? = null, + val elevation: Int = DEFAULT_ELEVATION, + val fixedTextSize: Float? = null, + val edgeSize: Float? = null, + val removeCaptions: Boolean = false, + val removeBloat: Boolean = true, + val upperCase: Boolean = false, + val bold: Boolean = false, + val italic: Boolean = false, + val backgroundRadius: Float? = null, + val alignment: Int? = null, +) { + val textSize: Float get() = fixedTextSize ?: DEFAULT_TEXT_SIZE + + companion object { + const val WHITE = 0xFFFFFFFF.toInt() + const val BLACK = 0xFF000000.toInt() + const val TRANSPARENT = 0 + + // CaptionStyleCompat's edge types + const val EDGE_NONE = 0 + const val EDGE_OUTLINE = 1 + const val EDGE_DROP_SHADOW = 2 + const val EDGE_RAISED = 3 + const val EDGE_DEPRESSED = 4 + + /** The size Android uses when none is set, in sp */ + const val DEFAULT_TEXT_SIZE = 25f + const val DEFAULT_ELEVATION = 20 + + /** The Android app's subtitle fonts, by the name it saves, with the family to look for on desktop */ + val fonts = linkedMapOf( + "Trebuchet" to "Trebuchet MS", + "Netflix" to "Netflix Sans", + "Google" to "Google Sans", + "Open" to "Open Sans", + "Futura" to "Futura", + "Consola" to "Consolas", + "Gotham" to "Gotham", + "Lucida" to "Lucida Sans Unicode", + "STIX" to "STIX Two Text", + "TimesNewRoman" to "Times New Roman", + "Verdana" to "Verdana", + "Ubuntu" to "Ubuntu", + "Comic" to "Comic Sans MS", + "Poppins" to "Poppins", + ) + } +} + +/** + * mpv's options for a style. mpv sizes text against a 720 pixel tall video whatever its real size, + * so Android's sizes are scaled to look about the same on a phone and a monitor: its 25 sp default + * becomes mpv's own default of 38. Subtitles with styles of their own (ASS) take this one instead, + * as on Android: release groups often ship plain dialogue as ASS, which would otherwise ignore it. + */ +fun SubtitleStyle.mpvOptions(): List> { + val edge = (edgeSize ?: 4f) / 2 + val (outline, shadow) = when (edgeType) { + SubtitleStyle.EDGE_NONE -> 0f to 0f + SubtitleStyle.EDGE_DROP_SHADOW -> 0f to edge + SubtitleStyle.EDGE_RAISED, SubtitleStyle.EDGE_DEPRESSED -> edge / 2 to 1.5f + else -> edge to 0f + } + // Android draws the background behind the text and the window behind the whole cue: mpv has one box + val box = backgroundColor.takeIf { alpha(it) > 0 } ?: windowColor.takeIf { alpha(it) > 0 } + return listOf( + "sub-font" to (font?.let { SubtitleStyle.fonts[it] } ?: "sans-serif"), + "sub-font-size" to (textSize * 1.5f).roundToInt().toString(), + "sub-color" to mpvColor(foregroundColor), + "sub-bold" to yesNo(bold), + "sub-italic" to yesNo(italic), + "sub-border-style" to if (box != null) "background-box" else "outline-and-shadow", + "sub-back-color" to mpvColor(box ?: SubtitleStyle.TRANSPARENT), + "sub-outline-size" to outline.toString(), + "sub-outline-color" to mpvColor(edgeColor), + "sub-shadow-offset" to shadow.toString(), + "sub-shadow-color" to mpvColor(edgeColor), + "sub-margin-y" to (elevation * 1.7f).roundToInt().coerceAtLeast(0).toString(), + "sub-ass-override" to "force", + ) +} + +private fun alpha(argb: Int) = argb ushr 24 + +/** mpv reads #AARRGGBB */ +private fun mpvColor(argb: Int) = "#" + argb.toUInt().toString(16).padStart(8, '0').uppercase() + +private fun yesNo(value: Boolean) = if (value) "yes" else "no" + +/** Languages as the Android app names them: IETF tags such as "en" or "pt-BR" */ +object SubtitleLanguages { + /** A subtitle's language, from a name ("English", "English SDH") or a code ("en", "eng") */ + fun tagOf(language: String?): String? = SubtitleHelper.fromLanguageToTagIETF(language) + ?: SubtitleHelper.fromCodeToLangTagIETF(language) + ?: SubtitleHelper.fromLanguageToTagIETF(language, halfMatch = true) + + /** The codes a video file may use for a language, which mpv matches its tracks with */ + fun codesOf(tag: String): List { + val language = SubtitleHelper.languages.firstOrNull { it.IETF_tag == tag } ?: return listOf(tag) + return listOf(language.IETF_tag, language.ISO_639_1, language.ISO_639_2_B, language.ISO_639_3) + .filter { it.isNotEmpty() } + .distinct() + } +} + +/** + * The subtitle settings: how they look and the language picked on its own. The player applies a + * change right away. + */ +interface SubtitleSettingsStore { + val style: StateFlow + + fun setStyle(style: SubtitleStyle) + + /** The language whose subtitles show on their own, as an IETF tag, or null for none */ + val autoSelect: StateFlow + + fun setAutoSelect(tag: String?) + + companion object { + /** The Android app's default: English */ + const val DEFAULT_AUTO_SELECT = "en" + } +} + +class InMemorySubtitleSettings( + style: SubtitleStyle = SubtitleStyle(), + autoSelect: String? = SubtitleSettingsStore.DEFAULT_AUTO_SELECT, +) : SubtitleSettingsStore { + private val _style = MutableStateFlow(style) + override val style: StateFlow = _style.asStateFlow() + + override fun setStyle(style: SubtitleStyle) { + _style.value = style + } + + private val _autoSelect = MutableStateFlow(autoSelect) + override val autoSelect: StateFlow = _autoSelect.asStateFlow() + + override fun setAutoSelect(tag: String?) { + _autoSelect.value = tag + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt new file mode 100644 index 00000000000..2893ac4400b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt @@ -0,0 +1,129 @@ +package com.lagradost.cloudstream4.providers + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.amap +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.newSearchResponseList +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.workerDispatcher +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.cancellation.CancellationException + +/** + * The providers the user wants to see, the same rules as filterProviderByPreferredMedia in the + * Android app: the provider language must be selected, unless all languages are, and it must + * support at least one preferred media type, unless none are selected. + * + * @param preferredMedia TvType ordinals as strings, as the prefer_media_type_key_2 setting stores them + */ +fun filterProviders( + apis: List, + languages: Set, + preferredMedia: Set, + requireMainPage: Boolean, +): List { + val allLanguages = languages.isEmpty() || languages.contains(AllLanguagesName) + val media = preferredMedia.mapNotNull { it.toIntOrNull() }.toSet() + return apis.filter { api -> + (allLanguages || languages.contains(api.lang)) && + (api.hasMainPage || !requireMainPage) && + (media.isEmpty() || api.supportedTypes.any { it.ordinal in media }) + } +} + +/** Drops results whose quality the user chose to hide in settings */ +fun List.withoutQualities(hidden: Set): List = + if (hidden.isEmpty()) this else filter { it.quality !in hidden } + +/* + * Calls into providers with the same limits as APIRepository in the Android app, so one slow or + * broken provider can never hang a screen. Errors come back as Resource.Failure instead of throwing. + */ + +private const val DEFAULT_TIMEOUT = 120_000L +private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT +private const val MIN_TIMEOUT = 5_000L + +internal fun providerTimeout(desired: Long?): Long = (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) + +/** Paged search, the first page is 1 */ +suspend fun MainAPI.searchSafely(query: String, page: Int): Resource { + if (query.isBlank()) return Resource.Success(newSearchResponseList(emptyList())) + return safeApiCall { + withTimeout(providerTimeout(searchTimeoutMs)) { + search(query, page) ?: throw ErrorLoadingException() + } + } +} + +/** Every home page section of this provider, the first page is 1 */ +suspend fun MainAPI.mainPageSafely(page: Int): Resource> = safeApiCall { + withTimeout(providerTimeout(getMainPageTimeoutMs)) { + lastHomepageRequest = APIHolder.unixTimeMS + val requests = mainPage.map { MainPageRequest(it.name, it.data, it.horizontalImages) } + if (sequentialMainPage) { + requests.mapIndexed { index, request -> + // Some sites block many requests at once, so these providers ask for a pause in between + if (index > 0) delay(sequentialMainPageDelay) + getMainPage(page, request) + } + } else { + requests.amap { getMainPage(page, it) } + } + } +} + +/** Providers use these as "nothing here", so they are never sent to load or loadLinks */ +private fun isInvalidData(data: String) = data.isEmpty() || data == "[]" || data == "about:blank" + +/** The details page of a search result or home page item */ +suspend fun MainAPI.loadSafely(url: String): Resource = safeApiCall { + withTimeout(providerTimeout(loadTimeoutMs)) { + if (isInvalidData(url)) throw ErrorLoadingException() + val response = load(fixUrl(url)) ?: throw ErrorLoadingException() + response.apply { tags = tags?.filter { it.isNotBlank() } } + } +} + +/** + * Finds the video links for a movie or episode. Links and subtitles are passed on as soon as they + * are found, from worker threads. Returns false if the provider failed or ran out of time. + */ +suspend fun MainAPI.loadLinksSafely( + data: String, + onSubtitle: (SubtitleFile) -> Unit, + onLink: (ExtractorLink) -> Unit, +): Boolean { + if (isInvalidData(data)) return false + return try { + withContext(workerDispatcher) { + withTimeout(providerTimeout(loadLinksTimeoutMs)) { + loadLinks(data, false, onSubtitle, onLink) + } + } + } catch (t: TimeoutCancellationException) { + logError(t) + false + } catch (t: Throwable) { + // Cancellation means the screen closed, which must reach the caller + if (t is CancellationException) throw t + logError(t) + false + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchHistory.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchHistory.kt new file mode 100644 index 00000000000..8d3a215f3b8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchHistory.kt @@ -0,0 +1,59 @@ +package com.lagradost.cloudstream4.search + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** A past search, keyed like the Android app: the query's hash, so searching it again moves it to the top */ +data class SearchHistoryEntry( + val key: String, + val query: String, + val searchedAt: Long, +) + +/** The searches the user ran, newest first */ +interface SearchHistoryStore { + /** Increases on every change, so screens know to read again */ + val version: StateFlow + + fun entries(): List + fun add(query: String) + fun remove(key: String) + fun clear() + + companion object { + /** The Android app's key for a query */ + fun keyOf(query: String) = query.hashCode().toString() + + /** The Android app saves searches of two or more letters */ + fun isSaved(query: String) = query.length > 1 + } +} + +class InMemorySearchHistory(private val clock: () -> Long = { System.currentTimeMillis() }) : SearchHistoryStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow = _version.asStateFlow() + private val entries = LinkedHashMap() + + private fun changed() = _version.update { it + 1 } + + override fun entries() = entries.values.sortedByDescending { it.searchedAt } + + override fun add(query: String) { + if (!SearchHistoryStore.isSaved(query)) return + val key = SearchHistoryStore.keyOf(query) + entries[key] = SearchHistoryEntry(key, query, clock()) + changed() + } + + override fun remove(key: String) { + entries.remove(key) + changed() + } + + override fun clear() { + entries.clear() + changed() + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt new file mode 100644 index 00000000000..236f22202a2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt @@ -0,0 +1,172 @@ +package com.lagradost.cloudstream4.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.providers.filterProviders +import com.lagradost.cloudstream4.providers.searchSafely +import com.lagradost.cloudstream4.providers.withoutQualities +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.job +import kotlinx.coroutines.launch + +sealed interface ProviderStatus { + data object Loading : ProviderStatus + data object Done : ProviderStatus + data class Failed(val message: String) : ProviderStatus +} + +/** The results one provider returned for the current query */ +data class ProviderResults( + val apiName: String, + val items: List = emptyList(), + val status: ProviderStatus = ProviderStatus.Loading, + val page: Int = 1, + val hasNext: Boolean = false, +) + +data class SearchState( + /** What is typed in the search box */ + val query: String = "", + /** The query the results below belong to, empty before the first search */ + val searchedQuery: String = "", + val results: List = emptyList(), + /** True when a search ran but no provider was enabled to search with */ + val noProviders: Boolean = false, + /** Past searches, newest first */ + val history: List = emptyList(), +) { + val isSearching: Boolean get() = results.any { it.status == ProviderStatus.Loading } +} + +sealed interface SearchAction { + data class QueryChanged(val query: String) : SearchAction + /** Searches every enabled provider for the typed query */ + data object Submit : SearchAction + /** Fetches the next page from one provider */ + data class LoadMore(val apiName: String) : SearchAction + data object Clear : SearchAction + /** Searches again for a past query */ + data class SearchFor(val query: String) : SearchAction + data class RemoveHistory(val key: String) : SearchAction + data object ClearHistory : SearchAction +} + +/** + * Searches all enabled providers at once. Each provider fills in its own row as soon as it + * answers, so a slow provider never holds back the others. + * + * @param apis all loaded providers, filtered here by the provider settings on every search + */ +class SearchViewModel( + private val apis: () -> List, + private val settings: AppSettings, + private val history: SearchHistoryStore = InMemorySearchHistory(), +) : ViewModel(), StateContainer by DefaultStateContainer(SearchState(history = history.entries())), + ActionHandler { + + init { + viewModelScope.launch { history.version.drop(1).collect { updateState { copy(history = this@SearchViewModel.history.entries()) } } } + } + + /** Parent of every request for the current query, cancelled when a new search starts */ + private var searchJob: Job = SupervisorJob() + /** Increases with every search, so a late answer for an old query is dropped */ + private var generation = 0 + private var searched: List = emptyList() + + override fun onAction(action: SearchAction) { + when (action) { + is SearchAction.QueryChanged -> updateState { copy(query = action.query) } + SearchAction.Submit -> search(state.value.query.trim()) + is SearchAction.LoadMore -> loadMore(action.apiName) + SearchAction.Clear -> { + cancel() + updateState { SearchState(history = history) } + } + + is SearchAction.SearchFor -> { + updateState { copy(query = action.query) } + search(action.query.trim()) + } + + is SearchAction.RemoveHistory -> history.remove(action.key) + SearchAction.ClearHistory -> history.clear() + } + } + + private fun cancel() { + searchJob.cancel() + generation++ + } + + private fun search(query: String) { + cancel() + if (query.isEmpty()) { + updateState { copy(searchedQuery = "", results = emptyList(), noProviders = false) } + return + } + history.add(query) + val provider = settings.provider + searched = filterProviders( + apis(), + languages = provider.extensionLanguages.get(), + preferredMedia = provider.preferredMedia.get(), + requireMainPage = false, + ) + updateState { + copy( + searchedQuery = query, + results = searched.map { ProviderResults(it.name) }, + noProviders = searched.isEmpty(), + ) + } + searchJob = SupervisorJob(viewModelScope.coroutineContext.job) + searched.forEach { fetch(it, query, page = 1) } + } + + private fun loadMore(apiName: String) { + val current = state.value.results.firstOrNull { it.apiName == apiName } ?: return + if (!current.hasNext || current.status == ProviderStatus.Loading) return + val api = searched.firstOrNull { it.name == apiName } ?: return + updateRow(generation, apiName) { copy(status = ProviderStatus.Loading) } + fetch(api, state.value.searchedQuery, current.page + 1) + } + + private fun fetch(api: MainAPI, query: String, page: Int) { + val forGeneration = generation + viewModelScope.launch(searchJob) { + val result = api.searchSafely(query, page) + val hidden = settings.ui.filterQuality.get() + updateRow(forGeneration, api.name) { + when (result) { + is Resource.Success -> copy( + items = (if (page == 1) emptyList() else items) + result.value.items.withoutQualities(hidden), + status = ProviderStatus.Done, + page = page, + hasNext = result.value.hasNext, + ) + + is Resource.Failure -> copy(status = ProviderStatus.Failed(result.errorString)) + is Resource.Loading -> this + } + } + } + } + + private fun updateRow(forGeneration: Int, apiName: String, reducer: ProviderResults.() -> ProviderResults) { + updateState { + // Checked inside the update, so a search started meanwhile always wins + if (forGeneration != generation) return@updateState this + copy(results = results.map { if (it.apiName == apiName) it.reducer() else it }) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt new file mode 100644 index 00000000000..9a650347b14 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt @@ -0,0 +1,156 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The app's buttons and chips: pills, as the dock and the bars are, the main action in the theme's + * accent and the others in its tonal surface colour. + */ +object AppShapes { + val button = RoundedCornerShape(percent = 50) + + /** dialog__window_background.xml */ + val dialog = RoundedCornerShape(10.dp) +} + +/** The main action, such as Play: the theme's accent. Named after WhiteButton in the Android app's styles.xml */ +@Composable +fun WhiteButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) = NiceButton( + onClick, modifier, enabled, contentPadding, + container = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + content = content, +) + +/** Every other action, such as Download: the theme's tonal surface. After BlackButton in styles.xml */ +@Composable +fun BlackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) = NiceButton( + onClick, modifier, enabled, contentPadding, + // iconGrayBackground + container = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onBackground, + content = content, +) + +/** After NiceButton in styles.xml: 44 dp high, no shadow, bold 15 sp text */ +@Composable +private fun NiceButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + contentPadding: PaddingValues, + container: Color, + contentColor: Color, + content: @Composable RowScope.() -> Unit, +) = Button( + onClick = onClick, + modifier = modifier.defaultMinSize(minHeight = 44.dp), + enabled = enabled, + shape = AppShapes.button, + colors = ButtonDefaults.buttonColors( + containerColor = container, + contentColor = contentColor, + disabledContainerColor = container.copy(alpha = 0.38f), + disabledContentColor = contentColor.copy(alpha = 0.6f), + ), + elevation = null, + contentPadding = contentPadding, +) { + ProvideTextStyle(MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp)) { + content() + } +} + +/** ChipFilled in styles.xml: grey without an outline, and the primary colour once selected */ +@Composable +fun AppFilterChip( + selected: Boolean, + onClick: () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val colors = MaterialTheme.colorScheme + FilterChip( + selected = selected, + onClick = onClick, + label = label, + modifier = modifier, + enabled = enabled, + border = null, + colors = FilterChipDefaults.filterChipColors( + // primaryGrayBackground + containerColor = colors.surfaceVariant, + labelColor = colors.onBackground, + iconColor = colors.onBackground, + selectedContainerColor = colors.primary, + selectedLabelColor = colors.onPrimary, + selectedLeadingIconColor = colors.onPrimary, + selectedTrailingIconColor = colors.onPrimary, + ), + ) +} + +/** + * Switches and sliders in the theme's colours, flat as in streaming apps: Material's own have an + * outline round an off switch, and dots along a slider for every step. + */ +object AppControls { + @Composable + fun switchColors(): SwitchColors { + val colors = MaterialTheme.colorScheme + return SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = colors.primary, + checkedBorderColor = Color.Transparent, + checkedIconColor = colors.primary, + // iconGrayBackground, a step off the page + uncheckedThumbColor = colors.onSurfaceVariant, + uncheckedTrackColor = colors.surface, + uncheckedBorderColor = Color.Transparent, + ) + } + + @Composable + fun sliderColors(): SliderColors { + val colors = MaterialTheme.colorScheme + return SliderDefaults.colors( + thumbColor = colors.primary, + activeTrackColor = colors.primary, + inactiveTrackColor = colors.surface, + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt index c6332808f02..ad32f267c38 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt @@ -34,26 +34,34 @@ internal object CloudStreamPalette { val PrimaryDark = Color(0xFF3700B3) val Ongoing = Color(0xFFF53B66) - val DarkPrimaryGrayBg = Color(0xFF2B2C30) - val DarkBlackBg = Color(0xFF111111) - val DarkIconGrayBg = Color(0xFF1C1C20) - val DarkBoxItemBg = Color(0xFF161616) - val DarkText = Color(0xFFE9EAEE) - val DarkGrayText = Color(0xFF9BA0A4) - val DarkIcon = Color(0xFF9BA0A6) + // The desktop's default dark, after Prime Video: a blue-black background, slate surfaces a + // step lighter, blue-gray secondary text and a sky-blue accent + val DarkPrimary = Color(0xFF1A98FF) + val DarkPrimaryGrayBg = Color(0xFF1B2530) + val DarkBlackBg = Color(0xFF0F171E) + val DarkIconGrayBg = Color(0xFF252E39) + val DarkBoxItemBg = Color(0xFF16202A) + val DarkText = Color(0xFFF2F4F5) + val DarkGrayText = Color(0xFF8197A4) + val DarkIcon = Color(0xFFAAB8C2) + /** The Android app's grayTextColor, which the other themes keep */ + val AndroidGrayText = Color(0xFF9BA0A4) // Amoled val AmoledBlack = Color(0xFF000000) - val AmoledNearBlack = Color(0xFF121212) + /** amoledModeLight in colors.xml */ + val AmoledNearBlack = Color(0xFF121213) - // Light - val LightPrimaryGrayBg = Color(0xFFF1F1F1) + // Light, the Prime Video dark turned over: a white page, cool blue-gray surfaces, the dark + // theme's blue-black for text and a deeper sky blue that reads on white + val LightPrimary = Color(0xFF0B7AD6) + val LightPrimaryGrayBg = Color(0xFFEEF2F5) val LightBlackBg = Color(0xFFFFFFFF) - val LightIconGrayBg = Color(0xFFEEEEEE) - val LightBoxItemBg = Color(0xFFEEEEEE) - val LightText = Color(0xFF202125) - val LightGrayText = Color(0xFF5F6267) - val LightIcon = Color(0xFF5F6267) + val LightIconGrayBg = Color(0xFFE3E9EE) + val LightBoxItemBg = Color(0xFFF3F6F8) + val LightText = Color(0xFF0F171E) + val LightGrayText = Color(0xFF5A6B78) + val LightIcon = Color(0xFF3E4F5C) // Dracula val DraculaPrimaryGrayBg = Color(0xFF414450) @@ -61,7 +69,6 @@ internal object CloudStreamPalette { val DraculaIconGrayBg = Color(0xFF44475A) val DraculaBoxItemBg = Color(0xFF373844) val DraculaText = Color(0xFFF8F8F2) - val DraculaGrayText = Color(0xFF6272A4) val DraculaIcon = Color(0xFF6272A4) // Lavender Dreams @@ -70,7 +77,6 @@ internal object CloudStreamPalette { val LavenderIconGrayBg = Color(0xFFB794F6) val LavenderBoxItemBg = Color(0xFFF8F5FF) val LavenderText = Color(0xFF2D1B47) - val LavenderGrayText = Color(0xFF9AB3FF) val LavenderIcon = Color(0xFF7C3AED) // Silent Blue @@ -79,6 +85,5 @@ internal object CloudStreamPalette { val SilentBlueIconGrayBg = Color(0xFF3A446A) val SilentBlueBoxItemBg = Color(0xFF3A446A) val SilentBlueText = Color(0xFFE0E1F3) - val SilentBlueGrayText = Color(0xFF7B83B0) val SilentBlueIcon = Color(0xFF7B83B0) } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt index d51c75d0822..5742846f487 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt @@ -92,21 +92,25 @@ internal fun darkScheme() = CloudStreamColorScheme( onBackground = CloudStreamPalette.DarkText, onSurfaceVariant = CloudStreamPalette.DarkGrayText, icon = CloudStreamPalette.DarkIcon, - primary = CloudStreamPalette.Primary, + primary = CloudStreamPalette.DarkPrimary, ongoing = CloudStreamPalette.Ongoing, isLight = false, ) +/** AmoledMode in styles.xml: black, with iconGrayBackground left at primaryBlackBackground */ internal fun amoledScheme() = darkScheme().copy( + primary = CloudStreamPalette.Primary, background = CloudStreamPalette.AmoledBlack, - surface = CloudStreamPalette.AmoledBlack, + surface = CloudStreamPalette.DarkBlackBg, surfaceVariant = CloudStreamPalette.AmoledBlack, surfaceContainer = CloudStreamPalette.AmoledBlack, ) +/** AmoledModeLight in styles.xml, the Android app's default */ internal fun amoledLightScheme() = amoledScheme().copy( surfaceVariant = CloudStreamPalette.AmoledNearBlack, + surfaceContainer = CloudStreamPalette.AmoledNearBlack, ) internal fun lightScheme() = CloudStreamColorScheme( @@ -117,7 +121,7 @@ internal fun lightScheme() = CloudStreamColorScheme( onBackground = CloudStreamPalette.LightText, onSurfaceVariant = CloudStreamPalette.LightGrayText, icon = CloudStreamPalette.LightIcon, - primary = CloudStreamPalette.Primary, + primary = CloudStreamPalette.LightPrimary, ongoing = CloudStreamPalette.Ongoing, isLight = true, ) @@ -128,7 +132,8 @@ internal fun draculaScheme() = CloudStreamColorScheme( surface = CloudStreamPalette.DraculaIconGrayBg, surfaceContainer = CloudStreamPalette.DraculaBoxItemBg, onBackground = CloudStreamPalette.DraculaText, - onSurfaceVariant = CloudStreamPalette.DraculaGrayText, + // The Android theme keeps the standard grayTextColor + onSurfaceVariant = CloudStreamPalette.AndroidGrayText, icon = CloudStreamPalette.DraculaIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, @@ -141,7 +146,8 @@ internal fun lavenderScheme() = CloudStreamColorScheme( surface = CloudStreamPalette.LavenderIconGrayBg, surfaceContainer = CloudStreamPalette.LavenderBoxItemBg, onBackground = CloudStreamPalette.LavenderText, - onSurfaceVariant = CloudStreamPalette.LavenderGrayText, + // The Android theme keeps the standard grayTextColor + onSurfaceVariant = CloudStreamPalette.AndroidGrayText, icon = CloudStreamPalette.LavenderIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, @@ -154,7 +160,8 @@ internal fun silentBlueScheme() = CloudStreamColorScheme( surface = CloudStreamPalette.SilentBlueIconGrayBg, surfaceContainer = CloudStreamPalette.SilentBlueBoxItemBg, onBackground = CloudStreamPalette.SilentBlueText, - onSurfaceVariant = CloudStreamPalette.SilentBlueGrayText, + // The Android theme keeps the standard grayTextColor + onSurfaceVariant = CloudStreamPalette.AndroidGrayText, icon = CloudStreamPalette.SilentBlueIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt index 196d8d6a1af..9e586d9935b 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream4.theme import androidx.compose.animation.core.InfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -65,6 +66,8 @@ fun modeToTheme(mode : CloudStreamThemeMode, primaryColor: CloudStreamPrimaryCol } when { mode == CloudStreamThemeMode.Dynamic -> base + // "Normal" is the theme's own accent, which is NORMAL's colour on all but the dark and light + primaryColor == CloudStreamPrimaryColor.NORMAL -> base primaryColor == CloudStreamPrimaryColor.DYNAMIC -> base.copy(primary = dynamicPrimary) primaryColor == CloudStreamPrimaryColor.DYNAMIC_TWO -> base.copy(primary = dynamicSecondary) else -> base.copy(primary = primaryColor.color) @@ -73,29 +76,39 @@ fun modeToTheme(mode : CloudStreamThemeMode, primaryColor: CloudStreamPrimaryCol return color } -private fun CloudStreamColorScheme.toMaterial3ColorScheme() = if (isLight) { - lightColorScheme( +/** + * Every Material role comes from the theme's own colours, as in the Android app's styles.xml. + * A role left out would keep Material's purple baseline, which shows in chips, dialogs, outlines + * and tonal surfaces. + */ +private fun CloudStreamColorScheme.toMaterial3ColorScheme(): ColorScheme { + val base = if (isLight) lightColorScheme() else darkColorScheme() + return base.copy( primary = primary, - background = background, - surface = surface, - surfaceVariant = surfaceVariant, - surfaceContainer = surfaceContainer, - onBackground = onBackground, - onSurface = onBackground, - onSurfaceVariant = onSurfaceVariant, onPrimary = Color.White, - ) -} else { - darkColorScheme( - primary = primary, + primaryContainer = primary, + onPrimaryContainer = Color.White, + inversePrimary = primary, + secondary = primary, + onSecondary = Color.White, + tertiary = primary, + onTertiary = Color.White, background = background, - surface = surface, - surfaceVariant = surfaceVariant, - surfaceContainer = surfaceContainer, onBackground = onBackground, + surface = surface, onSurface = onBackground, + surfaceVariant = surfaceVariant, onSurfaceVariant = onSurfaceVariant, - onPrimary = Color.White, + surfaceTint = primary, + surfaceDim = background, + surfaceBright = surfaceVariant, + surfaceContainerLowest = background, + surfaceContainerLow = surface, + surfaceContainer = surfaceContainer, + surfaceContainerHigh = surfaceVariant, + surfaceContainerHighest = surfaceVariant, + outline = onSurfaceVariant, + outlineVariant = surfaceVariant, ) } diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt index e5444159553..2fe5f322a07 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt @@ -96,6 +96,11 @@ fun AppBar( scrollBehavior: TopAppBarScrollBehavior? = null, ) { + val barColor = backgroundColor ?: if (isActionMode) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + MaterialTheme.colorScheme.background + } Column( modifier = modifier, ) { @@ -121,9 +126,10 @@ fun AppBar( title = titleContent, actions = actions, colors = TopAppBarDefaults.topAppBarColors( - containerColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceColorAtElevation( - elevation = if (isActionMode) 3.dp else 0.dp, - ), + // The page's own colour, so the bar is one with it, as in the rest of the app, also + // when the list scrolls under it + containerColor = barColor, + scrolledContainerColor = barColor, ), scrollBehavior = scrollBehavior, ) diff --git a/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt b/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt index 105c38ab802..495770da4b7 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.theme.AppControls import kotlin.math.roundToInt @Composable @@ -23,22 +24,26 @@ fun Slider( valueRange: IntProgression = 0..1, @IntRange(from = 0) steps: Int = with(valueRange) { (last - first) - 1 }, onValueChangeFinished: (() -> Unit)? = null, - colors: SliderColors = SliderDefaults.colors(), + colors: SliderColors = AppControls.sliderColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, thumb: @Composable (SliderState) -> Unit = { SliderDefaults.Thumb( interactionSource = interactionSource, colors = colors, enabled = enabled, - thumbSize = DpSize(4.0.dp, 34.0.dp) + // A round knob on a thin track + thumbSize = DpSize(18.dp, 18.dp) ) }, track: @Composable (SliderState) -> Unit = { sliderState -> SliderDefaults.Track( - modifier = Modifier.height(10.dp), + modifier = Modifier.height(6.dp), colors = colors, enabled = enabled, - sliderState = sliderState + sliderState = sliderState, + drawStopIndicator = null, + drawTick = { _, _ -> }, + thumbTrackGapSize = 0.dp, ) }, ) { diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt index 4f97d9f2b18..9b07078a146 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt @@ -2,6 +2,11 @@ package com.mihon.presentation.settings import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -22,28 +27,35 @@ fun PreferenceScaffold( ) { val (top, bottom) = remember { FocusRequester.createRefs() } - Scaffold( - // TODO @WindowInsets remove this when we have converted everything to compose - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - AppBar( - modifier = Modifier.focusRequester(top).focusProperties { - down = bottom - }, - title = title, - navigateUp = onBackPressed, - actions = actions, - scrollBehavior = it, - ) - }, - content = { contentPadding -> - PreferenceScreen( - modifier = Modifier.focusRequester(bottom).focusProperties { - up = top - }, - items = itemsProvider(), - contentPadding = contentPadding, - ) - }, - ) + // A readable width, centred, rather than rows across a whole wide window + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Scaffold( + modifier = Modifier.widthIn(max = PreferenceMaxWidth), + // TODO @WindowInsets remove this when we have converted everything to compose + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + AppBar( + modifier = Modifier.focusRequester(top).focusProperties { + down = bottom + }, + title = title, + navigateUp = onBackPressed, + actions = actions, + scrollBehavior = it, + ) + }, + content = { contentPadding -> + PreferenceScreen( + modifier = Modifier.focusRequester(bottom).focusProperties { + up = top + }, + items = itemsProvider(), + contentPadding = contentPadding, + ) + }, + ) + } } + +/** How wide a settings page grows */ +val PreferenceMaxWidth = 880.dp diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt index 0e99aa9a3c0..a0f3937f8a9 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt @@ -5,7 +5,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.draw.clip import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -56,31 +62,66 @@ fun PreferenceScreen( PreferenceGroupHeader(title = preference.title) } } - items(preference.preferenceItems) { item -> - PreferenceItem( - item = item, - highlightKey = highlightKey, - ) + val last = preference.preferenceItems.lastIndex + itemsIndexed(preference.preferenceItems) { j, item -> + PreferenceCard(first = j == 0, last = j == last) { + PreferenceItem( + item = item, + highlightKey = highlightKey, + ) + } } item { if (i < items.lastIndex) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } } - // Create Preference Item + // Create Preference Item, in one stack of cards with the items around it outside groups is Preference.PreferenceItem<*, *> -> item { - PreferenceItem( - item = preference, - highlightKey = highlightKey, - ) + if (i == 0) Spacer(modifier = Modifier.height(8.dp)) + PreferenceCard( + first = items.getOrNull(i - 1) !is Preference.PreferenceItem<*, *>, + last = items.getOrNull(i + 1) !is Preference.PreferenceItem<*, *>, + ) { + PreferenceItem( + item = preference, + highlightKey = highlightKey, + ) + } } } } } } +/** + * A group's items as a stack of cards in the theme's box colour, a hair apart, rounded at the top of + * the first and the bottom of the last, so the group reads as one block + */ +@Composable +private fun PreferenceCard(first: Boolean, last: Boolean, content: @Composable () -> Unit) { + val outer = 12.dp + val inner = 4.dp + val shape = RoundedCornerShape( + topStart = if (first) outer else inner, + topEnd = if (first) outer else inner, + bottomStart = if (last) outer else inner, + bottomEnd = if (last) outer else inner, + ) + Box( + Modifier + .padding(horizontal = PreferenceCardGutter) + .padding(bottom = if (last) 0.dp else 2.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { content() } +} + +/** The space at the sides of the cards; the group titles line up with what is inside them */ +internal val PreferenceCardGutter = 16.dp + private fun List.findHighlightedIndex(highlightKey: String): Int { return flatMap { if (it is Preference.PreferenceGroup) { diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt index 3382ebda5c8..70823cad32e 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt @@ -2,6 +2,8 @@ package com.mihon.presentation.settings.widget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -22,14 +24,16 @@ import com.mihon.material.padding @Composable internal fun InfoWidget(text: String) { - Column( + // The icon beside the text, not on a line of its own + Row( modifier = Modifier .padding( horizontal = PrefsHorizontalPadding, vertical = MaterialTheme.padding.medium, ) .secondaryItemAlpha(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium), + verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(Res.drawable.info), diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt index b7d1197a939..9aa7faf8c94 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.font.FontWeight @Composable fun PreferenceGroupHeader(title: String) { @@ -18,11 +19,12 @@ fun PreferenceGroupHeader(title: String) { .fillMaxWidth() .padding(bottom = 8.dp, top = 14.dp), ) { + // Grey and lined up with the text in the cards below it Text( text = title, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), - style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = PrefsHorizontalPadding * 2), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), ) } } diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt index 2744cdcdc9b..cf0beb00df8 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import com.lagradost.cloudstream4.theme.AppControls import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -31,6 +32,7 @@ fun SwitchPreferenceWidget( Switch( checked = checked, onCheckedChange = null, + colors = AppControls.switchColors(), modifier = Modifier.padding(start = TrailingWidgetBuffer), ) }, diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt new file mode 100644 index 00000000000..e8585e94567 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt @@ -0,0 +1,197 @@ +package com.lagradost.cloudstream4 + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * Erasing the app's data, and noticing when the app was installed again so the user can choose to + * start fresh. + * + * Erasing happens when the app starts, before anything opens these files: a loaded extension keeps + * its jar locked on Windows, and the settings file is kept open. So the reset options in Settings + * only schedule a reset and restart the app. + */ +class AppData( + private val root: Path, + private val cache: Path, + /** The installed app's executable, null when running from a build */ + private val installedExecutable: Path? = System.getProperty("jpackage.app-path")?.let(Path::of), +) { + enum class Reset(val title: String) { + /** Posters and other downloaded files the app can fetch again */ + CACHE("Clear the cache"), + + /** Installed extensions and their files. Repositories, settings and the library stay */ + EXTENSIONS("Remove all extensions"), + + /** Everything, as if the app had never been used */ + EVERYTHING("Erase all data"), + } + + private val pendingFile = root.resolve("reset-pending") + private val installFile = root.resolve("install-id") + private val updatingFile = root.resolve("updating") + + /** Erases the data on the next start, see [runPendingReset] */ + fun scheduleReset(reset: Reset) { + root.createDirectories() + pendingFile.writeText(reset.name) + } + + /** Erases what [scheduleReset] asked for. Call first thing at startup */ + fun runPendingReset() { + if (!pendingFile.exists()) return + val reset = runCatching { Reset.valueOf(pendingFile.readText().trim()) }.getOrNull() + pendingFile.deleteIfExists() + reset?.let(::reset) + } + + /** + * Erases right away. Only safe before the app has opened any of these files. After a restart the + * old app may still be closing and holding files, so locked files are tried again for a few seconds. + */ + fun reset(reset: Reset) { + repeat(RETRIES) { attempt -> + if (resetOnce(reset)) return + if (attempt < RETRIES - 1) Thread.sleep(500) + } + println("WARNING AppData: Some files could not be deleted for ${reset.name}") + } + + /** Returns true when everything was deleted */ + private fun resetOnce(reset: Reset): Boolean { + var complete = true + // The install id is kept, it describes the installed app, not its data + val keep = setOf(installFile.name) + fun delete(path: Path) { + if (path.exists() && !path.toFile().deleteRecursively()) complete = false + } + fun deleteContents(dir: Path) { + if (dir.isDirectory()) dir.listDirectoryEntries().forEach(::delete) + } + when (reset) { + Reset.CACHE -> { + deleteContents(cache) + deleteContents(root.resolve("android").resolve("cache")) + } + Reset.EXTENSIONS -> { + delete(root.resolve("plugins")) + // The app's own data store, which holds the library, is kept like on Android + val android = root.resolve("android") + if (android.isDirectory()) android.listDirectoryEntries().filter { it.name != "shared_prefs" }.forEach(::delete) + val prefs = android.resolve("shared_prefs") + if (prefs.isDirectory()) prefs.listDirectoryEntries().filter { it.name != APP_DATA_STORE }.forEach(::delete) + } + Reset.EVERYTHING -> { + deleteContents(cache) + if (root.isDirectory()) root.listDirectoryEntries().filter { it.name !in keep }.forEach(::delete) + } + } + return complete + } + + /** True when the app has data from earlier use, such as settings or extensions */ + fun hasData(): Boolean = root.isDirectory() && + root.listDirectoryEntries().any { it.name !in setOf(installFile.name, updatingFile.name, "logs") } + + /** Called before the app starts an installer to update itself, so the next start does not ask to start fresh */ + fun markUpdating() { + root.createDirectories() + updatingFile.writeText("1") + } + + /** + * True when the app was installed again since it last ran and data from before is there: then the + * user is asked whether to keep it. Updates through the app do not count. The first time a version + * with this check runs it only notes the install. + */ + fun isReinstallWithOldData(): Boolean { + val current = currentInstallId() ?: return false + val updating = updatingFile.exists() + val previous = if (installFile.exists()) installFile.readText().trim() else null + return previous != null && previous != current && !updating && hasData() + } + + /** Notes the current install, call after [isReinstallWithOldData] was answered */ + fun rememberInstall() { + updatingFile.deleteIfExists() + val current = currentInstallId() ?: return + root.createDirectories() + installFile.writeText(current) + } + + /** Reinstalling recreates the executable, so its creation time tells installs apart */ + private fun currentInstallId(): String? { + val exe = installedExecutable?.takeIf { it.exists() } ?: return null + return Files.readAttributes(exe, BasicFileAttributes::class.java).creationTime().toMillis().toString() + } + + companion object { + private const val RETRIES = 10 + + /** DataStore's PREFERENCES_NAME, the app's settings store that also holds the library */ + private const val APP_DATA_STORE = "rebuild_preference.json" + + val instance: AppData by lazy { AppData(AppDirs.root, AppDirs.cache) } + } +} + +/** The app's version, set by the build as cloudstream.version and by the installer as jpackage.app-version */ +object AppVersion { + val current: String = + System.getProperty("cloudstream.version") ?: System.getProperty("jpackage.app-version") ?: "0.0.0" + + /** + * Compares versions such as 4.10.0 and 4.9.2 number by number. A leading "v" is ignored. As in + * semantic versioning, a pre-release such as 4.8.0-beta.2 comes before 4.8.0 and after 4.8.0-beta.1 + */ + fun isNewer(candidate: String, than: String): Boolean = compare(candidate, than) > 0 + + private fun compare(a: String, b: String): Int { + fun split(v: String) = v.trim().removePrefix("v").split('-', limit = 2).let { it[0] to it.getOrNull(1) } + val (coreA, preA) = split(a) + val (coreB, preB) = split(b) + val numbersA = coreA.split('.').map { it.toIntOrNull() ?: 0 } + val numbersB = coreB.split('.').map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(numbersA.size, numbersB.size)) { + val x = numbersA.getOrElse(i) { 0 } + val y = numbersB.getOrElse(i) { 0 } + if (x != y) return x.compareTo(y) + } + return when { + preA == preB -> 0 + preA == null -> 1 + preB == null -> -1 + else -> comparePreRelease(preA, preB) + } + } + + /** beta.2 after beta.1, beta.10 after beta.9, rc after beta */ + private fun comparePreRelease(a: String, b: String): Int { + val partsA = a.split('.') + val partsB = b.split('.') + for (i in 0 until maxOf(partsA.size, partsB.size)) { + val x = partsA.getOrNull(i) ?: return -1 + val y = partsB.getOrNull(i) ?: return 1 + val nx = x.toIntOrNull() + val ny = y.toIntOrNull() + val result = when { + nx != null && ny != null -> nx.compareTo(ny) + nx != null -> -1 + ny != null -> 1 + else -> x.compareTo(y) + } + if (result != 0) return result + } + return 0 + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt new file mode 100644 index 00000000000..473d875dea7 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream4 + +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Where the desktop app keeps its files. + * + * Windows: %APPDATA%\CloudStream, macOS: ~/Library/Application Support/CloudStream, + * Linux: $XDG_CONFIG_HOME/CloudStream or ~/.config/CloudStream. + * + * Set the system property `cloudstream.home` to use another folder, for example a portable install. + */ +object AppDirs { + private const val APP_NAME = "CloudStream" + + val root: Path by lazy { + System.getProperty("cloudstream.home")?.takeIf { it.isNotBlank() }?.let { return@lazy Paths.get(it) } + + val home = System.getProperty("user.home") + val os = System.getProperty("os.name").orEmpty().lowercase() + when { + os.contains("win") -> Paths.get(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", APP_NAME) + os.contains("mac") -> Paths.get(home, "Library", "Application Support", APP_NAME) + else -> Paths.get(System.getenv("XDG_CONFIG_HOME") ?: "$home/.config", APP_NAME) + } + } + + val settingsFile: Path get() = root.resolve("settings.json") + val logs: Path get() = root.resolve("logs") + + /** Files that can be thrown away, such as poster images. On Windows this is the local, not roaming, AppData */ + val cache: Path by lazy { + val os = System.getProperty("os.name").orEmpty().lowercase() + val local = System.getenv("LOCALAPPDATA") + if (System.getProperty("cloudstream.home").isNullOrBlank() && os.contains("win") && local != null) { + Paths.get(local, APP_NAME, "cache") + } else { + root.resolve("cache") + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt index cca2d70d79e..d0bb892f63e 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt @@ -1,16 +1,16 @@ package com.lagradost.cloudstream4 import androidx.compose.runtime.Composable -import com.mihon.common.preference.InMemoryPreferenceStore +import com.mihon.common.preference.JvmPreferenceStore +import com.mihon.common.preference.PreferenceStore -/** As this is mostly for testing so far, we can assume we have a shared global for settings */ -internal val settings = AppSettings() +/** The single preference file for the desktop app, shared by settings and app state such as window size */ +val desktopPreferences: PreferenceStore by lazy { JvmPreferenceStore(AppDirs.settingsFile) } + +/** The desktop app has one window, so a shared global for settings is enough */ +internal val settings by lazy { AppSettings(desktopPreferences) } @Composable actual fun rememberAppSettings(): AppSettings { return settings } - -fun AppSettings() : AppSettings { - return AppSettings(preferences = InMemoryPreferenceStore()) -} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/QuietPrintStream.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/QuietPrintStream.kt new file mode 100644 index 00000000000..545b65703bc --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/QuietPrintStream.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream4 + +import java.io.OutputStream +import java.io.PrintStream + +/** + * A console stream that leaves out NiceHttp's noise about cancelled requests. When an extension stops + * its other requests, after one failed or once it has what it needs, NiceHttp prints a line and a + * whole stack trace for each: "Exception in NiceHttp: java.io.IOException Canceled", then + * "java.io.IOException: Canceled" and its frames. Android sends that to its system log, where nobody + * sees it; here it filled the app's log with dozens of traces at once. Every other line passes. + * + * A stack trace is printed under the stream's lock, so its lines come one after another. + */ +class QuietPrintStream(out: OutputStream) : PrintStream(out, true, Charsets.UTF_8) { + /** Inside a dropped trace: its frames and causes go too */ + private var dropping = false + + @Synchronized + override fun println(x: Any?) = filter(x.toString()) { super.println(x) } + + @Synchronized + override fun println(x: String?) = filter(x.toString()) { super.println(x) } + + private inline fun filter(line: String, print: () -> Unit) { + when { + line == CANCELED_LINE -> return + line == CANCELED_TRACE -> dropping = true + // Frames, "... 12 more", suppressed and causes are indented or start with "Caused by" + dropping && (line.startsWith("\t") || line.startsWith("Caused by: ")) -> {} + else -> { + dropping = false + print() + } + } + } + + companion object { + private const val CANCELED_LINE = "Exception in NiceHttp: java.io.IOException Canceled" + private const val CANCELED_TRACE = "java.io.IOException: Canceled" + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt new file mode 100644 index 00000000000..5f96c2d437f --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt @@ -0,0 +1,655 @@ +package com.lagradost.cloudstream4.browser + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.lagradost.cloudstream3.network.DesktopWebView +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.player.HumanChecks +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.io.File +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * The browser behind extensions' WebViewResolver and CloudflareKiller on desktop: Microsoft Edge, + * which comes with Windows, or Chrome, run with its own profile in the app's cache folder and driven + * through the DevTools protocol. It runs headless, without a window, and Cloudflare checks that pass + * by themselves are passed. A page whose check wants a box ticked fails at once, and its site is + * [asked]: the player leaves those sites for last, and only when nothing else plays offers to [pass] + * their checks, in a window. The cookies of a passed check come back here. The browser closes after + * a few idle minutes, and when the app exits. + */ +class SystemBrowser( + private val executable: File, + private val profile: File = AppDirs.cache.resolve("browser").toFile(), + /** Without a window. False shows the browser, to watch what a page does */ + private val headless: Boolean = true, +) : DesktopWebView, HumanChecks { + private val json = jacksonObjectMapper() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val startLock = Mutex() + private val http = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build() + + /** [browser] is the browser's main process, null when it could not be told */ + private class Connection(val browser: ProcessHandle?, val socket: WebSocket) + + @Volatile private var connection: Connection? = null + private val nextId = AtomicInteger(1) + private val pending = ConcurrentHashMap>() + /** Events of each page, by its DevTools session */ + private val sessions = ConcurrentHashMap>() + private val loads = AtomicInteger(0) + private var idleJob: Job? = null + /** The hidden tab that keeps the browser's session, see [start] */ + @Volatile private var keeper: String? = null + /** A check per site at a time in a window: the cookies once passed, else null */ + private val checks = ConcurrentHashMap?>>() + /** Sites whose check wants a person, with the page that asked and the user agent it had */ + private val blocked = ConcurrentHashMap>() + private val _asked = MutableSharedFlow(extraBufferCapacity = 32) + override val asked = _asked.asSharedFlow() + + @Volatile override var userAgent: String? = null + private set + + /** Called with the browser's process id before its window comes forward, which Windows must allow */ + @Volatile var onShow: ((Long) -> Unit)? = null + + override suspend fun load( + request: Request, + userAgent: String?, + script: String?, + onScript: ((String) -> Unit)?, + timeoutMs: Long, + onRequest: (Request) -> Boolean, + ) { + loads.incrementAndGet() + idleJob?.cancel() + try { + start() + val target = newTab() + try { + val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() + val events = Channel(Channel.UNLIMITED) + sessions[session] = events + try { + browse(request, userAgent, script, onScript, timeoutMs, onRequest, target, session, events) + } finally { + sessions.remove(session) + events.close() + } + } finally { + runCatching { call("Target.closeTarget", mapOf("targetId" to target)) } + } + } finally { + if (loads.decrementAndGet() == 0) idleJob = scope.launch { + delay(IDLE_CLOSE_MS) + if (loads.get() == 0) stop() + } + } + } + + private suspend fun browse( + request: Request, + userAgent: String?, + script: String?, + onScript: ((String) -> Unit)?, + timeoutMs: Long, + onRequest: (Request) -> Boolean, + target: String, + session: String, + events: Channel, + ) { + call("Network.enable", session = session) + useAgent(userAgent, session) + // Pictures, fonts and media are not needed to find links, as on Android + call("Network.setBlockedURLs", mapOf("urls" to BLOCKED), session) + val extra = request.headers.toMap().filterKeys { it.lowercase() !in setOf("user-agent", "referer", "cookie") } + if (extra.isNotEmpty()) call("Network.setExtraHTTPHeaders", mapOf("headers" to extra), session) + val referer = request.header("Referer") + call("Page.navigate", buildMap { put("url", request.url.toString()); if (referer != null) put("referrer", referer) }, session) + + val deadline = Deadline(timeoutMs) + val checker = handleChallenge(request.url, userAgent ?: this.userAgent, session, deadline) + try { + untilDeadline(deadline) { + for (event in events) { + if (event.path("method").asText() != "Network.requestWillBeSent") continue + val sent = event.path("params").path("request") + val seen = toRequest(sent) ?: continue + if (script != null) { + runCatching { evaluateJson(script, session) }.getOrNull()?.let { onScript?.invoke(it) } + } + if (onRequest(seen)) break + } + } + } finally { + checker.cancel() + } + } + + /** The extension's user agent, else the browser's own without "Headless", which sites refuse */ + private suspend fun useAgent(agent: String?, session: String) { + (agent ?: userAgent)?.let { call("Network.setUserAgentOverride", mapOf("userAgent" to it), session) } + } + + /** When a page gives up waiting: after its time, or earlier, once its site wants a check only a person can pass */ + private class Deadline(val ms: Long) { + val ended = CompletableDeferred() + fun end() { + ended.complete(Unit) + } + } + + /** Runs [block] until it finishes, its time runs out, or the [deadline] is ended early */ + private suspend fun untilDeadline(deadline: Deadline, block: suspend () -> Unit) = coroutineScope { + val work = launch { withTimeoutOrNull(deadline.ms) { block() } } + val stopper = launch { + deadline.ended.await() + work.cancel() + } + work.join() + stopper.cancel() + } + + private suspend fun challenged(session: String): Boolean { + val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() + return CHALLENGE_TITLES.any { title.contains(it, ignoreCase = true) } + } + + /** + * A page still on Cloudflare's check after a while wants a box ticked, which a browser without a + * window cannot do. The page stops waiting, like a site that is down, and the site is [asked], so + * the user can [pass] its check once nothing else plays. + */ + private fun handleChallenge(url: HttpUrl, agent: String?, session: String, deadline: Deadline) = scope.launch { + delay(CHALLENGE_WAIT_MS) + if (!challenged(session)) return@launch + println("INFO SystemBrowser: ${url.host} wants a human check, its links are left for last") + blocked[url.host] = url to agent + _asked.tryEmit(url.host) + deadline.end() + } + + override suspend fun pass(host: String): Boolean { + val (url, agent) = blocked[host] ?: return false + // Several asking at once share one window + val check = checks.computeIfAbsent(host) { + scope.async { + val window = SystemBrowser(executable, profile.resolveSibling("${profile.name}-check"), headless = false) + window.onShow = onShow + window.passInWindow(url, agent) + } + } + val cookies = try { + check.await() + } finally { + checks.remove(host, check) + } ?: return false + return runCatching { + start() + call("Storage.setCookies", mapOf("cookies" to cookies.map(::cookieParam))) + blocked.remove(host) + true + }.getOrElse { + println("WARN SystemBrowser: could not use the cookies of $host's check: $it") + false + } + } + + /** + * Opens [url] in this browser's window for the user to pass the site's check, and closes it after. + * Returns the site's cookies once passed, or null when the window was closed or the time ran out. + */ + private suspend fun passInWindow(url: HttpUrl, agent: String?): List? = try { + start() + val target = newTab() + val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() + // The cookie only counts for the user agent that passed + useAgent(agent, session) + call("Page.navigate", mapOf("url" to url.toString()), session) + val window = call("Browser.getWindowForTarget", mapOf("targetId" to target)).path("windowId").asInt() + call("Browser.setWindowBounds", mapOf("windowId" to window, "bounds" to mapOf("windowState" to "normal"))) + connection?.browser?.pid()?.let { onShow?.invoke(it) } + call("Target.activateTarget", mapOf("targetId" to target)) + call("Page.bringToFront", session = session) + withTimeoutOrNull(CHECK_TIMEOUT_MS) { + while (true) { + delay(1_000) + // Throws once the user closes the window + val title = evaluate("document.title", session).orEmpty() + if (title.isNotBlank() && CHALLENGE_TITLES.none { title.contains(it, ignoreCase = true) }) break + } + val domain = url.host.removePrefix("www.") + call("Storage.getCookies").path("cookies").filter { domain.endsWith(it.path("domain").asText().trimStart('.')) } + }.also { println("INFO SystemBrowser: ${url.host}'s check ${if (it == null) "was not passed in time" else "was passed"}") } + } catch (e: Exception) { + println("INFO SystemBrowser: ${url.host}'s check was not passed: $e") + null + } finally { + stop() + } + + /** A cookie as Storage.getCookies gives it, in the form Storage.setCookies takes */ + private fun cookieParam(cookie: JsonNode): Map = buildMap { + listOf("name", "value", "domain", "path").forEach { put(it, cookie.path(it).asText()) } + put("secure", cookie.path("secure").asBoolean()) + put("httpOnly", cookie.path("httpOnly").asBoolean()) + cookie.path("sameSite").takeIf { it.isTextual }?.let { put("sameSite", it.asText()) } + if (!cookie.path("session").asBoolean()) put("expires", cookie.path("expires").asDouble()) + } + + private fun toRequest(sent: JsonNode): Request? = runCatching { + val url = sent.path("url").asText() + if (!url.startsWith("http")) return null + Request.Builder().url(url).apply { + sent.path("headers").properties().forEach { (name, value) -> header(name, value.asText()) } + val method = sent.path("method").asText("GET") + if (method != "GET") { + val body = sent.path("postData").asText("").toByteArray() + method(method, if (method == "HEAD") null else body.toRequestBody()) + } + }.build() + }.getOrNull() + + private suspend fun evaluate(expression: String, session: String): String? = + call("Runtime.evaluate", mapOf("expression" to expression, "returnByValue" to true), session).path("result").path("value").takeIf { !it.isMissingNode }?.asText() + + /** The script's result as JSON, like Android's evaluateJavascript */ + private suspend fun evaluateJson(expression: String, session: String): String { + val value = call("Runtime.evaluate", mapOf("expression" to expression, "returnByValue" to true, "awaitPromise" to true), session).path("result").path("value") + return if (value.isMissingNode) "null" else json.writeValueAsString(value) + } + + override suspend fun fetch(request: Request): Response? { + loads.incrementAndGet() + idleJob?.cancel() + try { + start() + val target = newTab() + try { + val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() + val events = Channel(Channel.UNLIMITED) + sessions[session] = events + try { + useAgent(null, session) + return fetchIn(target, session, events, request) + } finally { + sessions.remove(session) + events.close() + } + } finally { + runCatching { call("Target.closeTarget", mapOf("targetId" to target)) } + } + } catch (e: Exception) { + println("WARN SystemBrowser: fetch of ${request.url} failed: $e") + return null + } finally { + if (loads.decrementAndGet() == 0) idleJob = scope.launch { + delay(IDLE_CLOSE_MS) + if (loads.get() == 0) stop() + } + } + } + + private suspend fun fetchIn(target: String, session: String, events: Channel, request: Request): Response? = + if (request.method == "GET") navigateIn(target, session, events, request) else fetchInPage(session, events, request) + + /** Loads a GET request as the page itself, which Cloudflare trusts more than a script's fetch() */ + private suspend fun navigateIn(target: String, session: String, events: Channel, request: Request): Response? { + val url = request.url + call("Network.enable", session = session) + call("Page.enable", session = session) + call("Network.setBlockedURLs", mapOf("urls" to BLOCKED), session) + val extra = request.headers.toMap().filterKeys { it.lowercase() !in setOf("user-agent", "referer", "cookie") } + if (extra.isNotEmpty()) call("Network.setExtraHTTPHeaders", mapOf("headers" to extra), session) + call("Page.navigate", buildMap { put("url", url.toString()); request.header("Referer")?.let { put("referrer", it) } }, session) + // The page's last document: Cloudflare's check marks its page, and loads the real one once passed + var document: JsonNode? = null + var finished = false + val deadline = Deadline(FETCH_TIMEOUT_MS) + val checker = handleChallenge(url, userAgent, session, deadline) + try { + untilDeadline(deadline) { + for (event in events) { + val params = event.path("params") + when (event.path("method").asText()) { + "Network.responseReceived" -> if (params.path("type").asText() == "Document") { + document = params + finished = false + } + + "Network.loadingFinished" -> if (params.path("requestId").asText() == document?.path("requestId")?.asText()) { + finished = true + } + + // Once the page is up, it is either the real one or Cloudflare's check, which loads the real one when passed + "Page.loadEventFired" -> if (finished) { + val marked = (document?.path("response")?.path("headers") as? ObjectNode)?.fields()?.asSequence() + ?.any { it.key.equals("cf-mitigated", ignoreCase = true) && it.value.asText() == "challenge" } == true + val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() + if (!marked && CHALLENGE_TITLES.none { title.contains(it, ignoreCase = true) }) break + } + } + } + } + } finally { + checker.cancel() + } + val done = document?.takeIf { finished } ?: return null + val response = done.path("response") + val body = call("Network.getResponseBody", mapOf("requestId" to done.path("requestId").asText()), session) + val bytes = if (body.path("base64Encoded").asBoolean()) Base64.getDecoder().decode(body.path("body").asText()) + else body.path("body").asText().toByteArray() + val headers = Headers.Builder().apply { + (response.path("headers") as? ObjectNode)?.fields()?.forEach { (name, value) -> + // Several values of a header come joined by new lines + value.asText().split('\n').forEach { runCatching { addUnsafeNonAscii(name, it) } } + } + // The body is already decoded + removeAll("Content-Encoding") + removeAll("Content-Length") + }.build() + println("INFO SystemBrowser: loaded $url in the page: ${response.path("status").asInt()}") + return Response.Builder() + .request(request.newBuilder().url(response.path("url").asText(url.toString())).build()) + .protocol(Protocol.HTTP_1_1) + .code(response.path("status").asInt()) + .message(response.path("statusText").asText("")) + .headers(headers) + .body(bytes.toResponseBody(headers["Content-Type"]?.toMediaTypeOrNull())) + .build() + } + + private suspend fun fetchInPage(session: String, events: Channel, request: Request): Response? { + // fetch() only reaches the page's own site: a small file of it is opened first + val url = request.url + val origin = "${url.scheme}://${url.host}" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else "" + call("Page.enable", session = session) + call("Page.navigate", mapOf("url" to "$origin/robots.txt"), session) + withTimeoutOrNull(20_000) { + for (event in events) if (event.path("method").asText() == "Page.loadEventFired") break + } + val init = buildMap { + put("method", request.method) + put("credentials", "include") + put("headers", request.headers.toMap().filterKeys { it.lowercase() !in FORBIDDEN_FETCH_HEADERS }) + request.header("Referer")?.let { put("referrer", it) } + request.body?.let { body -> + val buffer = okio.Buffer() + body.writeTo(buffer) + put("body", buffer.readUtf8()) + } + } + val script = """ + (async () => { + const r = await fetch(${json.writeValueAsString(url.toString())}, ${json.writeValueAsString(init)}); + const bytes = new Uint8Array(await r.arrayBuffer()); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + return { status: r.status, url: r.url, headers: [...r.headers.entries()], body: btoa(binary) }; + })() + """.trimIndent() + val evaluated = call("Runtime.evaluate", mapOf("expression" to script, "returnByValue" to true, "awaitPromise" to true), session) + val result = evaluated.path("result").path("value") + if (result.isMissingNode || !result.has("status")) { + println("WARN SystemBrowser: fetch of $url failed in the page: ${evaluated.path("exceptionDetails").path("exception").path("description").asText(evaluated.toString().take(300))}") + return null + } + println("INFO SystemBrowser: fetched $url in the page: ${result.path("status").asInt()}") + val headers = Headers.Builder().apply { + result.path("headers").forEach { pair -> runCatching { addUnsafeNonAscii(pair[0].asText(), pair[1].asText()) } } + }.build() + val body = Base64.getDecoder().decode(result.path("body").asText()) + return Response.Builder() + .request(request.newBuilder().url(result.path("url").asText(url.toString())).build()) + .protocol(Protocol.HTTP_1_1) + .code(result.path("status").asInt()) + .message("") + .headers(headers) + .body(body.toResponseBody(headers["Content-Type"]?.toMediaTypeOrNull())) + .build() + } + + override fun cookies(url: String): String? { + val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return null + if (connection == null) return null + val all = runBlocking { withTimeout(10_000) { call("Storage.getCookies") } }.path("cookies") + val matching = all.filter { cookie -> + val domain = cookie.path("domain").asText().trimStart('.') + host == domain || host.endsWith(".$domain") + } + if (matching.isEmpty()) return null + return matching.joinToString("; ") { "${it.path("name").asText()}=${it.path("value").asText()}" } + } + + /** Sends one DevTools command and waits for its result */ + private suspend fun call(method: String, params: Map = emptyMap(), session: String? = null): JsonNode { + val socket = connection?.socket ?: throw IllegalStateException("The browser is not running") + val id = nextId.getAndIncrement() + val answer = CompletableDeferred() + pending[id] = answer + val message = buildMap { put("id", id); put("method", method); put("params", params); if (session != null) put("sessionId", session) } + if (!socket.send(json.writeValueAsString(message))) { + pending.remove(id) + throw IllegalStateException("The browser closed") + } + val reply = try { + withTimeout(CALL_TIMEOUT_MS) { answer.await() } + } finally { + pending.remove(id) + } + reply.path("error").takeIf { !it.isMissingNode }?.let { throw IllegalStateException("$method: ${it.path("message").asText()}") } + return reply.path("result") + } + + private suspend fun start() = startLock.withLock { + connection?.takeIf { it.browser?.isAlive != false }?.let { return } + connection = null + profile.mkdirs() + val portFile = profile.resolve("DevToolsActivePort") + portFile.delete() + val launcher = ProcessBuilder( + executable.absolutePath, + "--remote-debugging-port=0", + "--user-data-dir=${profile.absolutePath}", + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-sync", + "--hide-crash-restore-bubble", + "--disable-features=msImplicitSignin,msEdgeWelcomePage", + if (headless) "--headless=new" else "--window-position=120,80", + "--window-size=1100,800", + "about:blank", + ).redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start() + + // The browser writes its DevTools port and address once it listens. Headless Edge starts + // its browser as another process and the one started here exits, so it is not waited on + val lines = withTimeoutOrNull(START_TIMEOUT_MS) { devToolsAddress(portFile) } + if (lines == null) { + launcher.destroyForcibly() + throw IllegalStateException("${executable.name} did not start") + } + val opened = CompletableDeferred() + val socket = http.newWebSocket( + Request.Builder().url("ws://127.0.0.1:${lines[0].trim()}${lines[1].trim()}").build(), + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + opened.complete(Unit) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val message = runCatching { json.readTree(text) }.getOrNull() ?: return + val id = message.path("id") + if (!id.isMissingNode) { + pending[id.asInt()]?.complete(message) + } else if (message.path("method").asText() == "Target.targetCreated") { + closePopup(message.path("params").path("targetInfo")) + } else { + message.path("sessionId").asText(null)?.let { sessions[it]?.trySend(message) } + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + opened.completeExceptionally(t) + failAll(t) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + failAll(IllegalStateException("The browser closed")) + } + }, + ) + withTimeout(START_TIMEOUT_MS) { opened.await() } + connection = Connection(launcher.toHandle().takeIf { it.isAlive }, socket) + // The browser's own process, to close it if it does not close when asked + runCatching { + call("SystemInfo.getProcessInfo").path("processInfo").firstOrNull { it.path("type").asText() == "browser" }?.path("id")?.asLong() + }.getOrNull()?.let(ProcessHandle::of)?.orElse(null)?.let { connection = Connection(it, socket) } + userAgent = runCatching { call("Browser.getVersion").path("userAgent").asText().replace("Headless", "") }.getOrNull() + // Windows lists Edge's windows in Alt+Tab, also those of a browser without a window, which can be + // neither seen nor closed there. Its tabs are hidden ones, which have no window, and the blank + // tab it starts with is closed. One hidden tab stays open: without any, Edge ends the session + // and forgets its session cookies. Popups that pages open get a window, so they are closed + runCatching { call("Target.setDiscoverTargets", mapOf("discover" to true)) } + if (headless) runCatching { + val keeper = newTab() + call("Target.getTargets").path("targetInfos") + .filter { it.path("type").asText() == "page" && it.path("targetId").asText() != keeper } + .forEach { call("Target.closeTarget", mapOf("targetId" to it.path("targetId").asText())) } + this.keeper = keeper + } + println("INFO SystemBrowser: started ${executable.name}, $userAgent") + } + + /** Opens a blank tab, without a window when headless, and returns its target */ + private suspend fun newTab(): String { + val hidden = if (headless) mapOf("hidden" to true, "background" to true) else emptyMap() + return call("Target.createTarget", mapOf("url" to "about:blank") + hidden).path("targetId").asText() + } + + /** The addresses of the open tabs, but for the one kept open */ + internal suspend fun tabs(): List = call("Target.getTargets").path("targetInfos") + .filter { it.path("type").asText() == "page" && it.path("targetId").asText() != keeper } + .map { it.path("url").asText() } + + /** Closes a tab a page opened, an ad most often: links are only looked for in the page itself */ + private fun closePopup(target: JsonNode) { + if (target.path("type").asText() != "page" || target.path("openerId").asText().isEmpty()) return + scope.launch { runCatching { call("Target.closeTarget", mapOf("targetId" to target.path("targetId").asText())) } } + } + + private suspend fun devToolsAddress(portFile: File): List { + while (true) { + val lines = runCatching { portFile.readLines() }.getOrNull() + if (lines != null && lines.size >= 2) return lines + delay(100) + } + } + + private fun failAll(error: Throwable) { + connection = null + pending.values.forEach { it.completeExceptionally(error) } + sessions.values.forEach { it.close() } + } + + /** Closes the browser, also when the app exits */ + fun stop() { + val current = connection ?: return + connection = null + runCatching { current.socket.send("""{"id":0,"method":"Browser.close"}""") } + current.browser?.let { browser -> + val closed = runCatching { browser.onExit().get(3, TimeUnit.SECONDS) }.isSuccess + if (!closed) { + browser.descendants().forEach { it.destroyForcibly() } + browser.destroyForcibly() + } + } + current.socket.cancel() + } + + private fun JsonNode.properties(): List> = + (this as? ObjectNode)?.fields()?.asSequence()?.map { it.key to it.value }?.toList().orEmpty() + + companion object { + /** A check that passes by itself is done by then */ + private const val CHALLENGE_WAIT_MS = 8_000L + /** How long the user has to pass a check in the window */ + private const val CHECK_TIMEOUT_MS = 3 * 60_000L + private const val CALL_TIMEOUT_MS = 30_000L + private const val START_TIMEOUT_MS = 20_000L + private const val IDLE_CLOSE_MS = 3 * 60_000L + private const val FETCH_TIMEOUT_MS = 60_000L + /** Headers a page's fetch() may not set: the browser sends its own */ + private val FORBIDDEN_FETCH_HEADERS = setOf( + "user-agent", "cookie", "referer", "origin", "host", "content-length", "connection", "accept-encoding", + "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", + ) + private val CHALLENGE_TITLES = listOf("Just a moment", "Attention Required", "Un instant") + private val BLOCKED = listOf( + "*.jpg", "*.jpeg", "*.png", "*.webp", "*.gif", "*.gifv", "*.mp4", "*.mp3", "*.webm", "*.mkv", + "*.avi", "*.mov", "*.flv", "*.ogg", "*.wav", "*.woff", "*.woff2", "*.ttf", "*.ts", "*.vtt", "*.srt", + "*/favicon.ico", + ) + + /** Edge, which comes with Windows, else Chrome or Chromium. Null when none is installed */ + fun find(): File? { + val env = System.getenv() + val windows = listOfNotNull(env["ProgramFiles(x86)"], env["ProgramFiles"], env["LOCALAPPDATA"]).flatMap { base -> + listOf("Microsoft\\Edge\\Application\\msedge.exe", "Google\\Chrome\\Application\\chrome.exe").map { File(base, it) } + } + val others = listOf( + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/microsoft-edge", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", + ).map(::File) + return (windows + others).firstOrNull { it.isFile } + } + + /** The browser for extensions, once installed */ + @Volatile var instance: SystemBrowser? = null + private set + + /** Installs the browser for extensions, when one is found */ + fun install(): SystemBrowser? { + val executable = find() ?: return null.also { println("WARN SystemBrowser: no Edge or Chrome found, extensions cannot use a WebView") } + val browser = SystemBrowser(executable) + instance = browser + DesktopWebView.engine = browser + Runtime.getRuntime().addShutdownHook(Thread { browser.stop() }) + return browser + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadNames.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadNames.kt new file mode 100644 index 00000000000..cb4af66350c --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadNames.kt @@ -0,0 +1,33 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased + +/** The Android app's download file and folder names, from DownloadFileManagement, in English */ +object DownloadNames { + private val reserved = Regex("""[|\\?*<":>+\[\]/']""") + + /** Characters Windows does not allow in a name become spaces, as on Android */ + fun sanitize(name: String): String = + name.replace(reserved, " ").replace(Regex("""\p{Cntrl}"""), " ").replace(Regex(" {2,}"), " ").trim() + // Windows drops a trailing dot, which would make the file impossible to find again + .trimEnd('.', ' ') + + /** "Movies", or "TVSeries/" for a type with episodes */ + fun folder(type: TvType, title: String): String { + val prefix = type.getFolderPrefix() + return if (type.isEpisodeBased()) "$prefix/${sanitize(title)}" else prefix + } + + /** + * The file name without its extension: the title for a movie, "Season 1 Episode 2 - Pilot", + * "Episode 2 - Pilot", "Season 1 Episode 2" or "Episode 2" for an episode + */ + fun fileName(title: String, episode: Int?, season: Int?, episodeName: String?): String { + if (episode == null) return sanitize(title) + val number = listOfNotNull(season?.let { "Season $it" }, "Episode $episode").joinToString(" ") + val name = episodeName?.takeIf { it.isNotBlank() } + return sanitize(if (name != null) "$number - $name" else number) + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt new file mode 100644 index 00000000000..c503ea1e980 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt @@ -0,0 +1,173 @@ +package com.lagradost.cloudstream4.download + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isEpisodeBased +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.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.io.File + +/** A title with downloads: a movie, or a series with its downloaded episodes */ +data class DownloadedTitle( + val id: Int, + val apiName: String, + val url: String, + val name: String, + val type: TvType, + val poster: String?, +) + +/** One downloaded or downloading file: an episode, or a movie with [episode] null */ +data class DownloadedEpisode( + val id: Int, + val parentId: Int, + val name: String?, + val episode: Int?, + val season: Int?, + val poster: String?, + val description: String?, +) + +/** Where a download is saved and how big it will be */ +data class DownloadFile( + /** The full size once done, an estimate while an HLS stream downloads */ + val totalBytes: Long, + /** The downloads folder picked in the settings when it started */ + val root: File, + /** For example "TVSeries/<title>", with forward slashes as on Android */ + val relativePath: String, + val displayName: String, + /** The HLS segments written so far, to carry on from there */ + val segmentsDone: Int? = null, + /** The file's length when [segmentsDone] was saved: a segment written after it is cut off on resume */ + val segmentBytes: Long? = null, + /** The url hash of the link it came from: carrying on with another link would mix two files */ + val linkHash: Int? = null, + /** The name of the source the user picked, to find it again when its url has changed */ + val linkName: String? = null, +) { + val folder: File get() = if (relativePath.isBlank()) root else root.resolve(relativePath) + val file: File get() = folder.resolve(displayName) +} + +/** What finds the links of a download again, to resume it after the app restarts */ +data class DownloadSource(val apiName: String, val data: String) + +/** + * Downloads in the Android app's keys and JSON, so its download list and the desktop's read the same + * data: the title in "download_header_cache", the episode in "download_episode_cache/<title id>" and + * the file in "download_info". Desktop adds "download_desktop_source" to find the links again. + */ +class DownloadStore(private val context: Context = DesktopAndroid.application) { + /** The Android app's DownloadHeaderCached, also written by the watch store for Continue watching */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class HeaderCached( + @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, + @JsonProperty("id") val id: Int, + ) + + /** The Android app's DownloadEpisodeCached. A movie is saved as episode 0 under its own id */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class EpisodeCached( + @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("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + @JsonProperty("id") val id: Int, + ) + + /** The Android app's DownloadedFileInfo, with an absolute folder as its base path */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class FileInfo( + @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, + @JsonProperty("linkHash") val linkHash: Int? = null, + /** Desktop only, Android ignores it */ + @JsonProperty("segmentBytes") val segmentBytes: Long? = null, + /** Desktop only */ + @JsonProperty("linkName") val linkName: String? = null, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class Source( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("data") val data: String, + ) + + fun saveTitle(title: DownloadedTitle) { + context.setKey("$HEADER/${title.id}", HeaderCached(title.apiName, title.url, title.type, title.name, title.poster, System.currentTimeMillis(), title.id)) + } + + fun title(id: Int): DownloadedTitle? = context.getKey("$HEADER/$id", HeaderCached::class.java) + ?.let { DownloadedTitle(it.id, it.apiName, it.url, it.name, it.type, it.poster) } + + fun saveEpisode(episode: DownloadedEpisode, source: DownloadSource) { + context.setKey( + "$EPISODE/${episode.parentId}/${episode.id}", + EpisodeCached(episode.name, episode.poster, episode.episode ?: 0, episode.season, episode.parentId, episode.description, System.currentTimeMillis(), episode.id), + ) + context.setKey("$SOURCE/${episode.id}", Source(source.apiName, source.data)) + } + + /** The episodes saved under a title, whether or not their file is there */ + fun episodes(parentId: Int): List<DownloadedEpisode> { + val movie = title(parentId)?.type?.isEpisodeBased() == false + return context.getKeys("$EPISODE/$parentId").mapNotNull { key -> + val cached = context.getKey(key, EpisodeCached::class.java) ?: return@mapNotNull null + // A movie is the title's own id, saved as episode 0 + val isMovie = movie && cached.id == parentId + DownloadedEpisode(cached.id, cached.parentId, cached.name, cached.episode.takeUnless { isMovie }, cached.season, cached.poster, cached.description) + } + } + + fun episode(parentId: Int, id: Int): DownloadedEpisode? = episodes(parentId).firstOrNull { it.id == id } + + /** The ids of every title with episodes saved */ + fun titleIds(): Set<Int> = context.getKeys(EPISODE).mapNotNull { it.split('/').getOrNull(1)?.toIntOrNull() }.toSet() + + fun source(id: Int): DownloadSource? = context.getKey("$SOURCE/$id", Source::class.java)?.let { DownloadSource(it.apiName, it.data) } + + fun saveFile(id: Int, file: DownloadFile) { + context.setKey( + "$INFO/$id", + FileInfo(file.totalBytes, file.relativePath, file.displayName, file.segmentsDone?.toString(), file.root.absolutePath, file.linkHash, file.segmentBytes, file.linkName), + ) + } + + fun file(id: Int): DownloadFile? { + val info = context.getKey("$INFO/$id", FileInfo::class.java) ?: return null + // Android keeps a content:// uri or nothing for its own Downloads folder, which desktop cannot use + val root = info.basePath?.takeUnless { it.isBlank() || it.startsWith("content:") }?.let(::File) ?: return null + return DownloadFile(info.totalBytes, root, info.relativePath, info.displayName, info.extraInfo?.toIntOrNull(), info.segmentBytes, info.linkHash, info.linkName) + } + + /** Forgets the file and the episode. The title stays, Continue watching may use it */ + fun remove(parentId: Int, id: Int) { + context.removeKey("$INFO/$id") + context.removeKey("$SOURCE/$id") + context.removeKey("$EPISODE/$parentId/$id") + } + + companion object { + private const val HEADER = "download_header_cache" + private const val EPISODE = "download_episode_cache" + private const val INFO = "download_info" + private const val SOURCE = "download_desktop_source" + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloader.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloader.kt new file mode 100644 index 00000000000..8efb7fcc693 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloader.kt @@ -0,0 +1,145 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.USER_AGENT +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.cloudstream4.network.DesktopHttp +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** How far a file is: bytes written, and the full size when known */ +fun interface DownloadProgress { + /** [segmentsDone] is set for HLS, after each segment is on disk */ + fun update(bytes: Long, total: Long, segmentsDone: Int?) +} + +/** + * Writes one link to a file: a plain video, carrying on where an earlier attempt stopped when the + * server allows it, or an HLS stream, its segments joined into one file as the Android app does. + */ +object Downloader { + /** Without the HTTP cache, which must not hold whole videos */ + private val http: OkHttpClient by lazy { + DesktopHttp.client.newBuilder().cache(null).readTimeout(30, TimeUnit.SECONDS).build() + } + + fun canDownload(link: ExtractorLink) = link.type == ExtractorLinkType.VIDEO || link.type == ExtractorLinkType.M3U8 + + /** The link's headers with a user agent and its referer, the link's own winning */ + fun headers(link: ExtractorLink): Map<String, String> { + val headers = link.headers.toMutableMap() + if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) headers["User-Agent"] = USER_AGENT + if (link.referer.isNotBlank() && headers.keys.none { it.equals("Referer", ignoreCase = true) }) headers["Referer"] = link.referer + return headers + } + + /** + * Downloads [link] to [file], carrying on from [resume] when an earlier attempt with the same link + * got that far. Throws when the link cannot be downloaded. + */ + suspend fun download(link: ExtractorLink, file: File, resume: Resume?, connections: Int, progress: DownloadProgress) { + file.parentFile?.mkdirs() + if (link.type == ExtractorLinkType.M3U8) hls(link, file, resume, connections, progress) + else video(link, file, if (resume != null) file.length() else 0, progress) + } + + /** How far an earlier attempt got: for HLS, the segments done and the file's length after them */ + data class Resume(val segments: Int, val bytes: Long) + + private suspend fun video(link: ExtractorLink, file: File, resumeFrom: Long, progress: DownloadProgress) = withContext(Dispatchers.IO) { + val start = if (resumeFrom > 0 && file.length() >= resumeFrom) resumeFrom else 0 + val request = Request.Builder().url(link.url.replace(" ", "%20")).apply { + headers(link).forEach { (key, value) -> header(key, value) } + if (start > 0) header("Range", "bytes=$start-") + }.build() + val call = http.newCall(request) + val cancel = currentCoroutineContext()[Job]?.invokeOnCompletion { call.cancel() } + try { + call.execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code}") + val type = response.header("Content-Type").orEmpty().lowercase() + // A web page where the video should be: an expired or protected link + if (type.startsWith("text/") || type.contains("json")) throw IOException("The link gave a web page, not a video") + // 206 carries on, a 200 sends the whole file again + val from = if (response.code == 206) start else 0 + val length = response.body.contentLength() + val total = if (length > 0) from + length else -1 + FileOutputStream(file, from > 0).use { out -> + var written = from + var reported = 0L + val buffer = ByteArray(256 * 1024) + response.body.byteStream().use { input -> + while (true) { + ensureActive() + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + val now = System.nanoTime() + if (now - reported > 500_000_000) { + reported = now + progress.update(written, total, null) + } + } + } + progress.update(written, if (total > 0) total else written, null) + if (total > 0 && written < total) throw IOException("The connection closed early") + } + } + } finally { + cancel?.dispose() + } + } + + private suspend fun hls(link: ExtractorLink, file: File, resume: Resume?, connections: Int, progress: DownloadProgress) { + val stream = M3u8Helper2.hslLazy(M3u8Helper.M3u8Stream(link.url, link.quality, headers(link)), selectBest = true, requireAudio = true) + val size = stream.size + if (size == 0) throw IOException("The stream has no segments") + // Only when the file still holds all the segments saved, without what came after them + val first = resume?.takeIf { file.length() >= it.bytes }?.segments?.coerceIn(0, size) ?: 0 + withContext(Dispatchers.IO) { + if (first > 0) java.io.RandomAccessFile(file, "rw").use { it.setLength(resume!!.bytes) } + FileOutputStream(file, first > 0).use { out -> + var written = file.length() + // A few segments load ahead while one is written, in order + val ahead = connections.coerceIn(1, 8) + coroutineScope { + val pending = ArrayDeque<Deferred<ByteArray?>>() + var next = first + fun fill() { + while (pending.size < ahead && next < size) { + val index = next++ + pending.addLast(async { stream.resolveLinkSafe(index) }) + } + } + fill() + var index = first + while (pending.isNotEmpty()) { + val bytes = pending.removeFirst().await() ?: throw IOException("Segment ${index + 1} of $size could not be loaded") + out.write(bytes) + out.flush() + written += bytes.size + index++ + // The size is known once all segments are in, until then it is guessed from those so far + progress.update(written, if (index == size) written else written / index * size, index) + fill() + } + } + } + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt new file mode 100644 index 00000000000..8f680f62dc9 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt @@ -0,0 +1,395 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream4.network.DesktopHttp +import com.lagradost.cloudstream4.player.DataStoreSubtitleSettings +import com.lagradost.cloudstream4.player.LinksViewModel +import com.lagradost.cloudstream4.player.SubtitleLanguages +import com.lagradost.cloudstream4.providers.loadLinksSafely +import com.lagradost.cloudstream4.settings +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +enum class DownloadStatus { Queued, FindingLinks, Downloading, Paused, Failed, Done } + +data class DownloadState( + val status: DownloadStatus, + val bytes: Long = 0, + /** The full size, 0 while unknown */ + val total: Long = 0, + /** Why it failed */ + val message: String? = null, + /** Failed because the source picked is gone: the user picks one again */ + val pickAgain: Boolean = false, +) { + val fraction: Float? get() = if (total > 0) (bytes.toFloat() / total).coerceIn(0f, 1f) else null + val active: Boolean get() = status == DownloadStatus.Queued || status == DownloadStatus.FindingLinks || status == DownloadStatus.Downloading +} + +/** + * What to download: an episode, or a movie, and what finds its links. [link] is the source the user + * picked, with the [subtitles] found beside it. Without, as when resuming, the links are found again + * and the download carries on with the source picked before + */ +data class DownloadRequest( + val title: DownloadedTitle, + val episode: DownloadedEpisode, + val source: DownloadSource, + val link: ExtractorLink? = null, + val subtitles: List<SubtitleFile> = emptyList(), +) + +/** Finds the links and subtitles of a download, as the player does */ +fun interface LinkFinder { + suspend fun find(source: DownloadSource, onLink: (ExtractorLink) -> Unit, onSubtitle: (SubtitleFile) -> Unit) +} + +/** + * Downloads episodes and movies to play offline, a few at a time, each from the source the user + * picked, as on Android: the best quality can be a file of many gigabytes. A download that stopped + * carries on where it was when it is resumed: its links are found again, and it goes on with the same + * source. Subtitles in the language picked for auto-select are saved beside the video, as on Android. + */ +class Downloads( + private val store: DownloadStore, + private val scope: CoroutineScope, + private val finder: LinkFinder, + /** The downloads folder from the settings */ + private val root: () -> File, + private val parallel: Int = 3, + private val connections: () -> Int = { 3 }, + private val subtitleLanguage: () -> String? = { null }, +) { + private val _states = MutableStateFlow<Map<Int, DownloadState>>(emptyMap()) + + /** Every download's state, by its episode id (a movie's is the title's id) */ + val states: StateFlow<Map<Int, DownloadState>> = _states.asStateFlow() + + private val jobs = ConcurrentHashMap<Int, Job>() + private val slots = Semaphore(parallel.coerceIn(1, 10)) + + init { + // Downloads from before: done, or stopped halfway and ready to carry on + val found = HashMap<Int, DownloadState>() + for (parentId in store.titleIds()) for (episode in store.episodes(parentId)) { + val file = store.file(episode.id) ?: continue + val length = file.file.length() + if (!file.file.isFile) continue + found[episode.id] = if (isComplete(length, file.totalBytes)) DownloadState(DownloadStatus.Done, length, length) + else DownloadState(DownloadStatus.Paused, length, file.totalBytes) + } + _states.value = found + } + + private fun set(id: Int, state: DownloadState?) = _states.update { if (state == null) it - id else it + (id to state) } + + /** Changes a download's state, unless it was deleted meanwhile */ + private fun change(id: Int, change: DownloadState.() -> DownloadState) = + _states.update { states -> states[id]?.let { states + (id to it.change()) } ?: states } + + fun state(id: Int): DownloadState? = states.value[id] + + /** Where new downloads go */ + val folder: File get() = root() + + /** Titles with a download, done or not, by name */ + fun titles(): List<DownloadedTitle> { + val ids = states.value.keys + return store.titleIds().mapNotNull { parentId -> + store.title(parentId)?.takeIf { store.episodes(parentId).any { it.id in ids } } + }.sortedBy { it.name.lowercase() } + } + + /** A title's downloads, in season and episode order */ + fun episodes(parentId: Int): List<DownloadedEpisode> { + val ids = states.value.keys + return store.episodes(parentId).filter { it.id in ids }.sortedWith(compareBy({ it.season ?: 0 }, { it.episode ?: 0 })) + } + + fun title(parentId: Int): DownloadedTitle? = store.title(parentId) + + /** The finished file of a download */ + fun file(id: Int): File? = store.file(id)?.file?.takeIf { state(id)?.status == DownloadStatus.Done && it.isFile } + + /** Subtitles saved beside a download: their language, as in the file name, and the file */ + fun subtitles(id: Int): List<Pair<String, File>> { + val video = file(id) ?: return emptyList() + val base = video.nameWithoutExtension + return subtitleFiles(video).map { it.nameWithoutExtension.removePrefix(base).trim().ifEmpty { "Subtitles" } to it }.sortedBy { it.first } + } + + /** "<video name> English.vtt" beside the video, but not "Episode 10 English.vtt" beside "Episode 1" */ + private fun subtitleFiles(video: File): List<File> { + val base = video.nameWithoutExtension + return video.parentFile?.listFiles().orEmpty().filter { + it != video && it.isFile && it.extension.lowercase() in SUBTITLE_EXTENSIONS && + (it.nameWithoutExtension == base || it.nameWithoutExtension.startsWith("$base ")) + } + } + + fun start(request: DownloadRequest) { + val id = request.episode.id + if (jobs[id]?.isActive == true || state(id)?.status == DownloadStatus.Done) return + store.saveTitle(request.title) + store.saveEpisode(request.episode, request.source) + val before = state(id) + // A new pick starts the file again, unless it is the same link + val restart = request.link != null && store.file(id)?.linkHash?.let { it != request.link.url.hashCode() } == true + set(id, DownloadState(DownloadStatus.Queued, if (restart) 0 else before?.bytes ?: 0, if (restart) 0 else before?.total ?: 0)) + jobs[id] = scope.launch { + try { + slots.withPermit { run(request) } + } catch (e: CancellationException) { + // Paused, or deleted, which has taken the state away already + change(id) { copy(status = DownloadStatus.Paused) } + throw e + } finally { + jobs.remove(id, coroutineContext[Job]) + } + } + } + + /** Carries on with a paused or failed download, finding its links again */ + fun resume(parentId: Int, id: Int) { + val title = store.title(parentId) ?: return + val episode = store.episode(parentId, id) ?: return + val source = store.source(id) ?: return + start(DownloadRequest(title, episode, source)) + } + + fun canResume(parentId: Int, id: Int) = store.source(id) != null && store.title(parentId) != null + + fun pause(id: Int) { + jobs[id]?.cancel() + } + + /** Stops the download and deletes its file and subtitles */ + fun delete(parentId: Int, id: Int) { + val job = jobs.remove(id) + val saved = store.file(id) + val files = listOfNotNull(saved?.file) + subtitlesOf(id) + set(id, null) + store.remove(parentId, id) + scope.launch { + // The file is open until the download stops, and Windows cannot delete an open file + job?.cancelAndJoin() + withContext(Dispatchers.IO) { + files.forEach { deleteFile(it) } + saved?.let { removeEmptyFolders(it.folder, it.root) } + } + } + } + + /** + * Deletes a file, trying again for a few seconds while something holds it open, as the player or + * a virus scan can on Windows. Its record is gone already, so a file left now would stay on the + * disk for good: one that still cannot go is deleted when the app closes. + */ + private suspend fun deleteFile(file: File) { + repeat(DELETE_TRIES) { + if (!file.exists() || file.delete()) return + delay(DELETE_RETRY_MS) + } + println("WARN Downloads: could not delete $file, it goes when the app closes") + file.deleteOnExit() + } + + private fun subtitlesOf(id: Int): List<File> = store.file(id)?.file?.let(::subtitleFiles).orEmpty() + + private suspend fun run(request: DownloadRequest) { + val id = request.episode.id + val previous = store.file(id) + val link: ExtractorLink + val subtitles: List<SubtitleFile> + if (request.link != null) { + link = request.link + subtitles = request.subtitles + } else { + change(id) { copy(status = DownloadStatus.FindingLinks, message = null, pickAgain = false) } + val found = try { + findAgain(request.source, previous) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + change(id) { copy(status = DownloadStatus.Failed, message = e.message ?: e.javaClass.simpleName) } + return + } + if (found == null) { + change(id) { copy(status = DownloadStatus.Failed, message = "The source picked before is gone, pick another", pickAgain = true) } + return + } + link = found.first + subtitles = found.second + } + + val title = request.title + val episode = request.episode + val name = DownloadNames.fileName(title.name, episode.episode, episode.season, episode.name) + val same = previous != null && previous.linkHash == link.url.hashCode() && previous.file.isFile + var file = DownloadFile( + totalBytes = if (same) previous!!.totalBytes else 0, + root = previous?.root ?: root(), + relativePath = previous?.relativePath ?: DownloadNames.folder(title.type, title.name), + displayName = previous?.displayName ?: "$name.mp4", + segmentsDone = if (same) previous!!.segmentsDone else null, + segmentBytes = if (same) previous!!.segmentBytes else null, + linkHash = link.url.hashCode(), + linkName = link.name, + ) + store.saveFile(id, file) + val resume = when { + !same -> null + link.type == ExtractorLinkType.M3U8 -> previous!!.segmentsDone?.let { Downloader.Resume(it, previous.segmentBytes ?: 0) } + else -> Downloader.Resume(0, 0) + } + change(id) { DownloadState(DownloadStatus.Downloading, if (same) file.file.length() else 0, file.totalBytes) } + var saved = 0L + try { + Downloader.download(link, file.file, resume, connections()) { bytes, total, segments -> + file = file.copy(totalBytes = maxOf(total, bytes), segmentsDone = segments ?: file.segmentsDone, segmentBytes = if (segments != null) bytes else file.segmentBytes) + change(id) { DownloadState(DownloadStatus.Downloading, bytes, file.totalBytes) } + // The store writes a whole file, so not on every update + val now = System.nanoTime() + if (now - saved > SAVE_EVERY_NS) { + saved = now + store.saveFile(id, file) + } + } + val length = file.file.length() + store.saveFile(id, file.copy(totalBytes = length)) + saveSubtitles(subtitles, file.copy(totalBytes = length)) + change(id) { DownloadState(DownloadStatus.Done, length, length) } + return + } catch (e: CancellationException) { + store.saveFile(id, file) + throw e + } catch (e: Throwable) { + store.saveFile(id, file) + val error = e.message ?: e.javaClass.simpleName + println("WARN Downloads: ${link.name} failed for ${title.name}: $error") + // Another source may work + change(id) { copy(status = DownloadStatus.Failed, message = error, pickAgain = true) } + } + } + + /** + * The source a download started with, among the links found again: the same url, else the same + * name, as a link's url often changes between visits. Null when it is gone + */ + private suspend fun findAgain(source: DownloadSource, previous: DownloadFile?): Pair<ExtractorLink, List<SubtitleFile>>? { + if (previous?.linkHash == null && previous?.linkName == null) return null + val subtitles = ArrayList<SubtitleFile>() + var byName: ExtractorLink? = null + var same: ExtractorLink? = null + coroutineScope { + lateinit var finding: Job + finding = launch { + finder.find( + source, + onLink = { link -> + synchronized(subtitles) { + if (link.url.hashCode() == previous.linkHash) same = link + else if (byName == null && link.name == previous.linkName && downloadable(link)) byName = link + } + // The same link is found: no need to wait for the other extractors + if (same != null) finding.cancel() + }, + onSubtitle = { sub -> synchronized(subtitles) { if (subtitles.none { it.url == sub.url }) subtitles += sub } }, + ) + } + } + val link = synchronized(subtitles) { same ?: byName } ?: return null + return link to synchronized(subtitles) { subtitles.toList() } + } + + /** Up to three subtitles in the language picked for auto-select, named after the video as on Android */ + private suspend fun saveSubtitles(subtitles: List<SubtitleFile>, file: DownloadFile) = withContext(Dispatchers.IO) { + val tag = subtitleLanguage() ?: return@withContext + val chosen = subtitles.filter { SubtitleLanguages.tagOf(it.lang) == tag }.take(3) + val base = file.file.nameWithoutExtension + chosen.forEachIndexed { index, sub -> + runCatching { + val extension = if (sub.url.contains(".srt")) "srt" else "vtt" + val label = DownloadNames.sanitize(sub.lang).ifEmpty { "Subtitles" } + if (chosen.size > 1) " ${index + 1}" else "" + val request = Request.Builder().url(sub.url).apply { + header("User-Agent", USER_AGENT) + sub.headers?.forEach { (key, value) -> header(key, value) } + }.build() + DesktopHttp.client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("HTTP ${response.code}") + file.folder.resolve("$base $label.$extension").writeBytes(response.body.bytes()) + } + }.onFailure { println("WARN Downloads: subtitle ${sub.lang} could not be saved: ${it.message}") } + } + } + + companion object { + private val SUBTITLE_EXTENSIONS = setOf("vtt", "srt", "txt", "ass", "ttml", "sbv", "dfxp") + private const val SAVE_EVERY_NS = 2_000_000_000L + private const val DELETE_TRIES = 10 + private const val DELETE_RETRY_MS = 500L + + /** + * Removes [folder] and the folders above it that a deleted download left empty, such as a + * series' folder after its last episode, up to [root], the downloads folder, which stays + */ + internal fun removeEmptyFolders(folder: File, root: File) { + val stop = root.canonicalFile + var current: File? = folder.canonicalFile + while (current != null && current != stop && current.startsWith(stop) && current.list()?.isEmpty() == true) { + if (!current.delete()) return + current = current.parentFile + } + } + + /** Links that can be saved to a file: not DRM, not torrents or web pages */ + fun downloadable(link: ExtractorLink) = link !is DrmExtractorLink && Downloader.canDownload(link) + + /** Links to pick from, best quality first */ + fun forPicking(links: List<ExtractorLink>) = links.filter(::downloadable).distinctBy { it.url }.sortedByDescending { LinksViewModel.rank(it.quality) } + + /** Done when within 1 KB of the full size, as on Android */ + fun isComplete(bytes: Long, total: Long) = total > 0 && bytes > 1024 && bytes + 1024 >= total + + /** The folder picked in the settings, or Downloads\CloudStream in the user's folder */ + fun defaultRoot(): File = File(System.getProperty("user.home"), "Downloads").resolve("CloudStream") + + val instance: Downloads by lazy { + Downloads( + store = DownloadStore(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + finder = { source, onLink, onSubtitle -> + val api = APIHolder.getApiFromNameNull(source.apiName) + ?: throw IllegalStateException("${source.apiName} is not loaded") + api.loadLinksSafely(source.data, onSubtitle = onSubtitle, onLink = onLink) + }, + root = { settings.general.downloadPath.get().takeIf { it.isNotBlank() }?.let(::File) ?: defaultRoot() }, + parallel = settings.general.parallelDownloads.get(), + connections = { settings.general.concurrentConnections.get() }, + subtitleLanguage = { DataStoreSubtitleSettings.instance.autoSelect.value }, + ) + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt new file mode 100644 index 00000000000..4795a28e30d --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt @@ -0,0 +1,174 @@ +package com.lagradost.cloudstream4.library + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +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.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * The watch data in the same keys and JSON as the Android app's DataStoreHelper, under its first + * account ("0/..."), so a later backup import can read either app's data. It lives in the same + * settings store extensions use through DataStore. + */ +class DataStoreWatchStore( + private val context: Context = DesktopAndroid.application, + private val clock: () -> Long = { System.currentTimeMillis() }, +) : WatchStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow<Int> = _version.asStateFlow() + + private fun changed() = _version.update { it + 1 } + + // The Android app's JSON shapes. Fields desktop does not use are kept out, Jackson ignores them on reading + + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class BookmarkedData( + @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, + @JsonProperty("id") val id: Int?, + @JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long, + @JsonProperty("name") val name: String, + @JsonProperty("url") val url: String, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("type") val type: TvType?, + @JsonProperty("posterUrl") val posterUrl: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("posterHeaders") val posterHeaders: Map<String, String>? = null, + @JsonProperty("plot") val plot: String? = null, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class PosDur( + @JsonProperty("position") val position: Long, + @JsonProperty("duration") val duration: Long, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + internal 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 = false, + ) + + /** What Continue watching shows for a title, the Android app's download header cache */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal 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, + @JsonProperty("id") val id: Int, + ) + + /** Desktop's own, the Android app has no such key */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class LastSource( + @JsonProperty("name") val name: String, + @JsonProperty("quality") val quality: Int, + ) + + private fun BookmarkedData.toHeader(id: Int) = TitleHeader(id, apiName, url, name, type, posterUrl, posterHeaders, year, plot) + + override fun bookmark(id: Int): Bookmark? = synchronized(this) { + val status = WatchType.fromInternalId(context.getKey("$ACCOUNT/$WATCH_STATE/$id", Int::class.javaObjectType)) + if (status == WatchType.NONE) return null + val data = context.getKey("$ACCOUNT/$WATCH_STATE_DATA/$id", BookmarkedData::class.java) ?: return null + Bookmark(data.toHeader(id), status, data.bookmarkedTime, data.latestUpdatedTime) + } + + override fun bookmarks(): List<Bookmark> = synchronized(this) { + context.getKeys("$ACCOUNT/$WATCH_STATE_DATA").mapNotNull { it.substringAfterLast('/').toIntOrNull()?.let(::bookmark) } + } + + override fun setBookmark(header: TitleHeader, status: WatchType) { + synchronized(this) { + val id = header.id + if (status == WatchType.NONE) { + context.removeKey("$ACCOUNT/$WATCH_STATE/$id") + context.removeKey("$ACCOUNT/$WATCH_STATE_DATA/$id") + } else { + val now = clock() + val since = context.getKey("$ACCOUNT/$WATCH_STATE_DATA/$id", BookmarkedData::class.java)?.bookmarkedTime ?: now + context.setKey("$ACCOUNT/$WATCH_STATE/$id", status.internalId) + context.setKey( + "$ACCOUNT/$WATCH_STATE_DATA/$id", + BookmarkedData(since, id, now, header.name, header.url, header.apiName, header.type, header.posterUrl, header.year, header.posterHeaders, header.plot), + ) + } + } + changed() + } + + override fun position(id: Int): PlaybackPosition? = + context.getKey("$ACCOUNT/$POS_DUR/$id", PosDur::class.java)?.let { PlaybackPosition(it.position, it.duration) } + + override fun setPosition(id: Int, positionMs: Long, durationMs: Long) { + if (durationMs < MIN_DURATION_MS) return + context.setKey("$ACCOUNT/$POS_DUR/$id", PosDur(positionMs, durationMs)) + changed() + } + + override fun resume(parentId: Int): ResumeEntry? = synchronized(this) { + val resume = context.getKey("$ACCOUNT/$RESUME_WATCHING/$parentId", ResumeWatching::class.java) ?: return null + val header = context.getKey("$HEADER_CACHE/$parentId", DownloadHeaderCached::class.java) ?: return null + ResumeEntry( + TitleHeader(parentId, header.apiName, header.url, header.name, header.type, header.poster), + resume.episodeId, resume.episode, resume.season, resume.updateTime, + ) + } + + override fun resumeEntries(): List<ResumeEntry> = synchronized(this) { + context.getKeys("$ACCOUNT/$RESUME_WATCHING").mapNotNull { it.substringAfterLast('/').toIntOrNull()?.let(::resume) } + } + + override fun setResume(entry: ResumeEntry) { + synchronized(this) { + val header = entry.header + context.setKey("$ACCOUNT/$RESUME_WATCHING/${header.id}", ResumeWatching(header.id, entry.episodeId, entry.episode, entry.season, entry.updateTime)) + context.setKey( + "$HEADER_CACHE/${header.id}", + DownloadHeaderCached(header.apiName, header.url, header.type ?: TvType.Movie, header.name, header.posterUrl, clock(), header.id), + ) + } + changed() + } + + override fun removeResume(parentId: Int) { + context.removeKey("$ACCOUNT/$RESUME_WATCHING/$parentId") + changed() + } + + override fun lastSource(parentId: Int): SavedSource? = + context.getKey("$ACCOUNT/$LAST_SOURCE/$parentId", LastSource::class.java)?.let { SavedSource(it.name, it.quality) } + + // Not a change screens show, so no new version + override fun setLastSource(parentId: Int, source: SavedSource) { + context.setKey("$ACCOUNT/$LAST_SOURCE/$parentId", LastSource(source.name, source.quality)) + } + + companion object { + /** The Android app's first account, DataStoreHelper.currentAccount */ + private const val ACCOUNT = "0" + private const val WATCH_STATE = "result_watch_state" + private const val WATCH_STATE_DATA = "result_watch_state_data" + private const val POS_DUR = "video_pos_dur" + private const val RESUME_WATCHING = "result_resume_watching_2" + private const val HEADER_CACHE = "download_header_cache" + private const val LAST_SOURCE = "desktop_last_source" + + val instance: DataStoreWatchStore by lazy { DataStoreWatchStore() } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/network/DesktopHttp.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/network/DesktopHttp.kt new file mode 100644 index 00000000000..98aded16cc1 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/network/DesktopHttp.kt @@ -0,0 +1,118 @@ +package com.lagradost.cloudstream4.network + +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.insecureApp +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.settings +import com.lagradost.nicehttp.ignoreAllSSLErrors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import okhttp3.Cache +import okhttp3.Call +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import java.io.File +import java.net.InetAddress +import java.util.concurrent.TimeUnit + +/** + * The one HTTP client of the app. Extensions (through `app`), posters, subtitles, repositories and + * updates all go through it, so they share one set of open connections and threads instead of each + * keeping its own, and repeated requests are answered from a disk cache when the server allows it. + * Built like the Android app's buildDefaultClient, with its DNS over HTTPS choices. + */ +object DesktopHttp { + /** The Android app's dns_key values: 0 is the system's DNS */ + val dnsProviders: Map<Int, String> = linkedMapOf( + 0 to "None", 1 to "Google", 2 to "Cloudflare", 4 to "AdGuard", 5 to "DNS.WATCH", + 6 to "Quad9", 7 to "DNS.SB", 8 to "Canadian Shield", + ) + + private val pool = ConnectionPool(maxIdleConnections = 10, keepAliveDuration = 2, timeUnit = TimeUnit.MINUTES) + private val dispatcher = Dispatcher() + + /** Where responses are cached, changed by tests only */ + @Volatile var cacheDir: File = AppDirs.cache.resolve("http").toFile() + + // One cache for every client built here: two caches on one folder would corrupt it + private val cache by lazy { Cache(cacheDir, CACHE_BYTES) } + + @Volatile private var dns = 0 + + @Volatile private var current: OkHttpClient? = null + + /** The client, with the DNS picked in the settings */ + val client: OkHttpClient + get() = current ?: synchronized(this) { current ?: build(dns).also { current = it } } + + /** + * Sets the DNS and makes the extensions use the client. Called at start and whenever the DNS + * setting changes, as the Android app does. + */ + fun install(dns: Int) { + synchronized(this) { + this.dns = dns + current = build(dns) + } + app.baseClient = client + // For the few extensions whose sites have broken certificates, as on Android + insecureApp.baseClient = client.newBuilder().ignoreAllSSLErrors().build() + } + + @Volatile private var uncachedClient: Pair<OkHttpClient, OkHttpClient>? = null + + /** + * Calls through the client without its cache, for loaders that keep a cache of their own, such as + * Coil. It follows DNS changes too + */ + val uncached: Call.Factory = Call.Factory { request -> + val base = client + val made = uncachedClient?.takeIf { it.first === base }?.second + ?: base.newBuilder().cache(null).build().also { uncachedClient = base to it } + made.newCall(request) + } + + /** Uses the DNS from the settings now and whenever it changes */ + fun start(scope: CoroutineScope) { + val dns = settings.general.dns + install(dns.get()) + scope.launch { dns.changes().drop(1).collect(::install) } + } + + private fun build(dns: Int): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectionPool(pool) + .dispatcher(dispatcher) + .cache(cache) + .followRedirects(true) + .followSslRedirects(true) + val (url, ips) = dohServers[dns] ?: return builder.build() + // The resolver's own lookups go through a client without it, by its fixed addresses + val bootstrap = builder.build() + return builder.dns( + DnsOverHttps.Builder() + .client(bootstrap) + .url(url.toHttpUrl()) + .bootstrapDnsHosts(ips.map { InetAddress.getByName(it) }) + .build() + ).build() + } + + /** The Android app's DohProviders */ + private val dohServers = mapOf( + 1 to ("https://dns.google/dns-query" to listOf("8.8.4.4", "8.8.8.8")), + 2 to ("https://cloudflare-dns.com/dns-query" to listOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")), + 4 to ("https://dns.adguard.com/dns-query" to listOf("94.140.14.140", "94.140.14.141")), + 5 to ("https://resolver2.dns.watch/dns-query" to listOf("84.200.69.80", "84.200.70.40")), + 6 to ("https://dns.quad9.net/dns-query" to listOf("9.9.9.9", "149.112.112.112")), + 7 to ("https://doh.dns.sb/dns-query" to listOf("185.222.222.222", "45.11.45.11")), + 8 to ("https://private.canadianshield.cira.ca/dns-query" to listOf("149.112.121.10", "149.112.122.10")), + ) + + /** As on Android */ + private const val CACHE_BYTES = 50L * 1024 * 1024 +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/DataStoreSubtitleSettings.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/DataStoreSubtitleSettings.kt new file mode 100644 index 00000000000..b36a43edf64 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/DataStoreSubtitleSettings.kt @@ -0,0 +1,91 @@ +package com.lagradost.cloudstream4.player + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Subtitle settings in the Android app's keys and JSON: "subtitle_settings" for the style and + * "subs_auto_select" for the language, next to the library in the same store. + */ +class DataStoreSubtitleSettings( + private val context: Context = DesktopAndroid.application, +) : SubtitleSettingsStore { + /** The Android app's SaveCaptionStyle */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class SaveCaptionStyle( + @JsonProperty("foregroundColor") val foregroundColor: Int, + @JsonProperty("backgroundColor") val backgroundColor: Int, + @JsonProperty("windowColor") val windowColor: Int, + @JsonProperty("edgeType") val edgeType: Int, + @JsonProperty("edgeColor") val edgeColor: Int, + @JsonProperty("font") val font: String? = null, + @JsonProperty("typefaceFilePath") val typefaceFilePath: String?, + @JsonProperty("elevation") val elevation: Int, + @JsonProperty("fixedTextSize") val fixedTextSize: Float?, + @JsonProperty("edgeSize") val edgeSize: Float? = null, + @JsonProperty("removeCaptions") val removeCaptions: Boolean = false, + @JsonProperty("removeBloat") val removeBloat: Boolean = true, + @JsonProperty("upperCase") val upperCase: Boolean = false, + @JsonProperty("bold") val bold: Boolean = false, + @JsonProperty("italic") val italic: Boolean = false, + @JsonProperty("backgroundRadius") val backgroundRadius: Float? = null, + @JsonProperty("alignment") val alignment: Int? = null, + ) { + fun toStyle() = SubtitleStyle( + foregroundColor, backgroundColor, windowColor, edgeType, edgeColor, font, typefaceFilePath, elevation, + fixedTextSize, edgeSize, removeCaptions, removeBloat, upperCase, bold, italic, backgroundRadius, alignment, + ) + + companion object { + fun of(style: SubtitleStyle) = with(style) { + SaveCaptionStyle( + foregroundColor, backgroundColor, windowColor, edgeType, edgeColor, font, typefaceFilePath, elevation, + fixedTextSize, edgeSize, removeCaptions, removeBloat, upperCase, bold, italic, backgroundRadius, alignment, + ) + } + } + } + + private val _style = MutableStateFlow( + runCatching { context.getKey(STYLE, SaveCaptionStyle::class.java)?.toStyle() }.getOrNull() ?: SubtitleStyle() + ) + override val style: StateFlow<SubtitleStyle> = _style.asStateFlow() + + override fun setStyle(style: SubtitleStyle) { + context.setKey(STYLE, SaveCaptionStyle.of(style)) + _style.value = style + } + + // Android saves its "None" entry as the word itself, in the app's language: anything that is not a + // language counts as none + private val _autoSelect = MutableStateFlow( + context.getKey(AUTO_SELECT, String::class.java).let { saved -> + if (saved == null) SubtitleSettingsStore.DEFAULT_AUTO_SELECT + else saved.takeIf { tag -> SubtitleHelper.languages.any { it.IETF_tag == tag } } + } + ) + override val autoSelect: StateFlow<String?> = _autoSelect.asStateFlow() + + override fun setAutoSelect(tag: String?) { + context.setKey(AUTO_SELECT, tag ?: NONE) + _autoSelect.value = tag + } + + companion object { + private const val STYLE = "subtitle_settings" + private const val AUTO_SELECT = "subs_auto_select" + + /** What the Android app saves for no language, in English */ + private const val NONE = "None" + + val instance: DataStoreSubtitleSettings by lazy { DataStoreSubtitleSettings() } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt new file mode 100644 index 00000000000..11713962f30 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -0,0 +1,320 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.plugins.PluginData +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.lagradost.cloudstream4.android.DexConverter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeBytes +import kotlin.io.path.writeText + +data class InstalledPlugin( + val internalName: String, + val name: String, + val version: Int, + val repositoryUrl: String, + /** Converted from the Android version, whose .cs3 is kept in the android folder */ + val isAndroid: Boolean = false, + /** The converter version that made the jar, see CONVERTER_VERSION */ + val convertedWith: Int = 0, + /** From the repository, with %size% where the size goes like Android */ + val iconUrl: String? = null, + /** The file it was downloaded from. Android extensions check it, see PluginData.url */ + val fileUrl: String? = null, + /** + * Methods of an Android plugin that could not be converted, or were too big to: they throw when + * called, so some of the plugin's sources may not work on desktop + */ + val disabledMethods: Int = 0, +) + +/** A newer version of an installed plugin in its repository */ +data class PluginUpdate(val installed: InstalledPlugin, val available: RepoPlugin) + +/** + * Installs plugin jars into one folder, keeps a list of them in installed.json and loads them. + * All changes go through one lock, so an install and an uninstall can never interleave. + * + * A plugin only built for Android is downloaded as its .cs3 and converted into a jar. The .cs3 is + * kept, so a later app version with a better converter converts it again without a download. + */ +class DesktopPluginManager( + private val dir: Path, + private val client: RepositoryClient = RepositoryClient(), +) { + private val indexFile = dir.resolve("installed.json") + private val lock = Mutex() + private val loaded = mutableMapOf<String, LoadedPlugin>() + + private val _installed = MutableStateFlow(readIndex()) + val installed: StateFlow<List<InstalledPlugin>> = _installed.asStateFlow() + + /** Why a plugin failed to load, by internal name */ + private val _errors = MutableStateFlow<Map<String, String>>(emptyMap()) + val errors: StateFlow<Map<String, String>> = _errors.asStateFlow() + + /** Internal names of the plugins that are loaded, changes whenever providers are added or removed */ + private val _loadedNames = MutableStateFlow<Set<String>>(emptySet()) + val loadedNames: StateFlow<Set<String>> = _loadedNames.asStateFlow() + + fun loadedPlugin(internalName: String): LoadedPlugin? = loaded[internalName] + + private fun publishLoaded() { + _loadedNames.value = loaded.keys.toSet() + } + + private fun jarFile(internalName: String): Path = inside(dir, "$internalName.jar") + private fun androidFile(internalName: String): Path = inside(dir.resolve("android"), "$internalName.cs3") + + /** A plugin's file, refusing a name that would land outside its folder */ + private fun inside(folder: Path, name: String): Path { + val file = folder.resolve(name).normalize() + require(RepositoryClient.isSafeInternalName(name.substringBeforeLast('.')) && file.parent == folder.normalize()) { + "\"$name\" is not a safe plugin file name" + } + return file + } + + init { + // Android plugins look themselves up in the Android app's plugin manager + PluginManager.installed = { + // Like Android, the url is the file's: some extensions only register when it is from their own repository + _installed.value.map { PluginData(it.internalName, it.fileUrl ?: it.repositoryUrl, true, jarFile(it.internalName).toString(), it.version) } + } + } + + /** Loads every installed plugin, called once at startup. A plugin that fails is skipped and its error kept */ + suspend fun loadAll() { + lock.withLock { + withContext(Dispatchers.IO) { + for (plugin in _installed.value) { + if (loaded.containsKey(plugin.internalName)) continue + if (plugin.isAndroid && plugin.convertedWith != CONVERTER_VERSION) reconvertLocked(plugin) + loadLocked(plugin.internalName) + } + } + } + // Android plugins wait for this to register things that need the other plugins + runCatching { MainActivity.afterPluginsLoadedEvent.invoke(false) } + .onFailure { println("ERROR DesktopPluginManager: A plugin failed in afterPluginsLoadedEvent: $it") } + } + + /** + * Downloads, verifies and loads a plugin, replacing an older version that is already installed. + * A plugin only built for Android is converted first, which takes a few seconds for large ones. + */ + suspend fun install(repositoryUrl: String, plugin: RepoPlugin) { + val android = !plugin.supportsDesktop + val bytes = if (android) client.downloadAndroidPlugin(plugin) else client.downloadJar(plugin) + lock.withLock { + withContext(Dispatchers.IO) { + dir.createDirectories() + val target = jarFile(plugin.internalName) + val cs3 = androidFile(plugin.internalName) + // The new version is made ready beside the old one, which keeps working if that fails + val newJar = target.resolveSibling("${target.fileName}.new") + val newCs3 = cs3.resolveSibling("${cs3.fileName}.new") + var disabled = 0 + try { + if (android) { + cs3.parent.createDirectories() + newCs3.writeBytes(bytes) + disabled = convert(plugin.internalName, newCs3, newJar) + } else { + newJar.writeBytes(bytes) + } + } catch (t: Throwable) { + newJar.deleteIfExists() + newCs3.deleteIfExists() + throw t + } + // Windows cannot replace a jar that is still loaded + loaded.remove(plugin.internalName)?.unload() + publishLoaded() + Files.move(newJar, target, REPLACE_EXISTING, ATOMIC_MOVE) + if (android) Files.move(newCs3, cs3, REPLACE_EXISTING, ATOMIC_MOVE) else cs3.deleteIfExists() + + val entry = InstalledPlugin( + plugin.internalName, plugin.name, plugin.version, repositoryUrl, + isAndroid = android, convertedWith = if (android) CONVERTER_VERSION else 0, + iconUrl = plugin.iconUrl, fileUrl = if (android) plugin.androidUrl else plugin.jarUrl, + disabledMethods = disabled, + ) + writeIndex(_installed.value.filterNot { it.internalName == plugin.internalName } + entry) + loadLocked(plugin.internalName)?.let { throw it } + } + } + } + + /** Installed plugins with a newer version in their repository. A repository that fails to load is skipped */ + suspend fun findUpdates(): List<PluginUpdate> = coroutineScope { + _installed.value.groupBy { it.repositoryUrl }.map { (url, plugins) -> + async { + val available = runCatching { client.fetchPlugins(client.fetchRepository(url)) } + .onFailure { println("WARNING DesktopPluginManager: Could not check $url for updates: $it") } + .getOrNull() ?: return@async emptyList() + plugins.mapNotNull { plugin -> + available.firstOrNull { it.internalName == plugin.internalName && it.version > plugin.version && it.canInstall } + ?.let { PluginUpdate(plugin, it) } + } + } + }.awaitAll().flatten() + } + + /** Installs the updates one after another. Returns why each one that failed did, by internal name */ + suspend fun installUpdates(updates: List<PluginUpdate>): Map<String, String> { + val failed = mutableMapOf<String, String>() + for (update in updates) { + runCatching { install(update.installed.repositoryUrl, update.available) } + .onFailure { failed[update.installed.internalName] = it.message ?: it.toString() } + } + return failed + } + + suspend fun uninstall(internalName: String) = lock.withLock { + withContext(Dispatchers.IO) { + loaded.remove(internalName)?.unload() + publishLoaded() + jarFile(internalName).deleteIfExists() + androidFile(internalName).deleteIfExists() + writeIndex(_installed.value.filterNot { it.internalName == internalName }) + _errors.update { it - internalName } + } + } + + /** Returns how many methods could not be converted */ + private fun convert(internalName: String, cs3: Path, target: Path): Int { + val start = System.nanoTime() + val result = try { + DexConverter.convert(cs3, target) + } catch (e: OutOfMemoryError) { + throw PluginDownloadException("$internalName is too large to convert with the memory the app has") + } catch (e: Exception) { + throw PluginDownloadException("Could not convert $internalName for desktop: ${e.message ?: e}") + } + val millis = (System.nanoTime() - start) / 1_000_000 + println("INFO DesktopPluginManager: Converted $internalName in $millis ms, ${result.failedMethods} methods failed") + return result.failedMethods + } + + /** Converts a plugin again after the converter changed. Without its .cs3 the old jar is kept */ + private fun reconvertLocked(plugin: InstalledPlugin) { + val cs3 = androidFile(plugin.internalName) + if (!cs3.exists()) return + runCatching { convert(plugin.internalName, cs3, jarFile(plugin.internalName)) } + .onSuccess { disabled -> + writeIndex( + _installed.value.map { + if (it.internalName == plugin.internalName) it.copy(convertedWith = CONVERTER_VERSION, disabledMethods = disabled) else it + }, + ) + } + .onFailure { println("ERROR DesktopPluginManager: Could not convert ${plugin.internalName} again: $it") } + } + + /** Returns the error if the plugin could not be loaded */ + private fun loadLocked(internalName: String): Throwable? { + val file = jarFile(internalName) + return try { + if (!file.exists()) throw PluginDownloadException("$file is missing, install the plugin again") + val android = _installed.value.firstOrNull { it.internalName == internalName }?.isAndroid ?: true + loaded[internalName] = PluginLoader.load(file, android = android) + publishLoaded() + _errors.update { it - internalName } + println("INFO DesktopPluginManager: Loaded $internalName") + null + } catch (t: Throwable) { + println("ERROR DesktopPluginManager: Could not load $internalName: $t") + t.printStackTrace() + _errors.update { it + (internalName to (t.message ?: t.toString())) } + t + } + } + + private fun readIndex(): List<InstalledPlugin> = runCatching { + if (!indexFile.exists()) return emptyList() + Json.parseToJsonElement(indexFile.readText()).jsonArray.map { element -> + val obj = element.jsonObject + InstalledPlugin( + internalName = obj["internalName"]!!.jsonPrimitive.content, + name = obj["name"]!!.jsonPrimitive.content, + version = obj["version"]!!.jsonPrimitive.int, + repositoryUrl = obj["repositoryUrl"]!!.jsonPrimitive.content, + isAndroid = obj["isAndroid"]?.jsonPrimitive?.boolean ?: false, + convertedWith = obj["convertedWith"]?.jsonPrimitive?.int ?: 0, + iconUrl = obj["iconUrl"]?.jsonPrimitive?.takeIf { it.isString }?.content, + fileUrl = obj["fileUrl"]?.jsonPrimitive?.takeIf { it.isString }?.content, + disabledMethods = obj["disabledMethods"]?.jsonPrimitive?.int ?: 0, + ) + } + }.getOrElse { + println("ERROR DesktopPluginManager: Could not read $indexFile: $it") + emptyList() + } + + private fun writeIndex(plugins: List<InstalledPlugin>) { + dir.createDirectories() + val json = JsonArray(plugins.map { + JsonObject( + mapOf( + "internalName" to JsonPrimitive(it.internalName), + "name" to JsonPrimitive(it.name), + "version" to JsonPrimitive(it.version), + "repositoryUrl" to JsonPrimitive(it.repositoryUrl), + "isAndroid" to JsonPrimitive(it.isAndroid), + "convertedWith" to JsonPrimitive(it.convertedWith), + "iconUrl" to JsonPrimitive(it.iconUrl), + "fileUrl" to JsonPrimitive(it.fileUrl), + "disabledMethods" to JsonPrimitive(it.disabledMethods), + ) + ) + }) + val tmp = dir.resolve("installed.json.tmp") + tmp.writeText(json.toString()) + Files.move(tmp, indexFile, REPLACE_EXISTING, ATOMIC_MOVE) + _installed.value = plugins + } + + companion object { + /** + * Raise when the conversion changes, so Android plugins installed before are converted again. + * 2: the methods that could not be converted are counted (disabledMethods) + */ + const val CONVERTER_VERSION = 2 + + /** The app's plugin manager, keeping plugins in the plugins folder next to the settings */ + val instance: DesktopPluginManager by lazy { + DesktopAndroid.init(AppDirs.root.resolve("android")) + DesktopPluginManager(AppDirs.root.resolve("plugins")) + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt new file mode 100644 index 00000000000..bec0ed22638 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt @@ -0,0 +1,111 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.plugins.BasePlugin +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin +import com.lagradost.cloudstream3.utils.extractorApis +import com.lagradost.cloudstream4.android.AndroidPluginClassLoader +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Path +import java.util.jar.JarFile + +/** A plugin jar that is loaded and running */ +class LoadedPlugin internal constructor( + val file: Path, + val instance: BasePlugin, + private val classLoader: AndroidPluginClassLoader, +) { + /** Android classes the plugin used that desktop only has empty stubs of, so those parts do nothing */ + val stubbedClasses: Set<String> get() = classLoader.stubbedClasses + + /** The providers this plugin registered when it loaded */ + val providers: List<MainAPI> + get() = APIHolder.allProviders.withLock { APIHolder.allProviders.filter { it.sourcePlugin == instance.filename } } + + /** Runs the plugin's beforeUnload, removes everything it registered and releases the jar file */ + fun unload() { + val filename = instance.filename + runCatching { instance.beforeUnload() }.onFailure { + println("ERROR PluginLoader: beforeUnload failed for $file: $it") + } + APIHolder.apis.filter { it.sourcePlugin == filename }.forEach(APIHolder::removePluginMapping) + APIHolder.allProviders.removeAll { it.sourcePlugin == filename } + extractorApis.removeAll { it.sourcePlugin == filename } + // Closing releases the file lock, which Windows needs before the jar can be replaced or deleted + classLoader.close() + } +} + +/** + * Loads a plugin jar: the cross-platform .jar a plugin publishes when it is built with + * `isCrossPlatform = true`, or one converted from an Android plugin by DexConverter. + * + * The cross-platform jar has no manifest.json, so the plugin class is the one annotated with + * @CloudstreamPlugin. A converted jar keeps the Android plugin's manifest.json, which is used first. + * Android plugins get load(context) with the desktop activity, like Android calls it with its activity. + */ +object PluginLoader { + private const val ANNOTATION_DESCRIPTOR = "Lcom/lagradost/cloudstream3/plugins/CloudstreamPlugin;" + + /** + * [android] is for a plugin converted from Android: what it uses that desktop lacks is stubbed. + * A desktop jar is built against the desktop library, so a missing class there is a real error + */ + fun load(file: Path, parent: ClassLoader = PluginLoader::class.java.classLoader, android: Boolean = true): LoadedPlugin { + val classLoader = AndroidPluginClassLoader(file, parent, stubMissing = android) + try { + val className = findPluginClassName(file) + ?: throw IllegalArgumentException("No class annotated with @CloudstreamPlugin in $file") + val pluginClass = classLoader.loadClass(className) + require(BasePlugin::class.java.isAssignableFrom(pluginClass)) { + "$className in $file does not extend BasePlugin" + } + val instance = pluginClass.getDeclaredConstructor().newInstance() as BasePlugin + instance.filename = file.toAbsolutePath().toString() + val loaded = LoadedPlugin(file, instance, classLoader) + try { + if (instance is Plugin) instance.load(DesktopAndroid.activity) else instance.load() + } catch (t: Throwable) { + loaded.unload() + throw t + } + return loaded + } catch (t: Throwable) { + runCatching { classLoader.close() } + throw t + } + } + + /** The plugin class from manifest.json, or else the top-level class carrying the annotation */ + internal fun findPluginClassName(file: Path): String? = JarFile(file.toFile()).use { jar -> + jar.getJarEntry("manifest.json")?.let { entry -> + val manifest = Json.parseToJsonElement(jar.getInputStream(entry).reader().readText()).jsonObject + manifest["pluginClassName"]?.jsonPrimitive?.content?.let { return@use it } + } + + // Scan the bytes for the annotation rather than loading every class, which would run their static initializers + val descriptor = ANNOTATION_DESCRIPTOR.toByteArray() + jar.entries().asSequence() + .filter { it.name.endsWith(".class") && !it.name.contains('$') && !it.name.startsWith("META-INF/") } + .firstOrNull { entry -> jar.getInputStream(entry).use { it.readBytes() }.contains(descriptor) } + ?.name?.removeSuffix(".class")?.replace('/', '.') + } + + private fun ByteArray.contains(other: ByteArray): Boolean { + if (other.isEmpty() || other.size > size) return false + outer@ for (i in 0..size - other.size) { + for (j in other.indices) if (this[i + j] != other[j]) continue@outer + return true + } + return false + } + + /** Keeps the annotation referenced so a rename in the library breaks this file at compile time */ + @Suppress("unused") + private val annotation = CloudstreamPlugin::class +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt new file mode 100644 index 00000000000..2509c275f73 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt @@ -0,0 +1,232 @@ +package com.lagradost.cloudstream4.plugins + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.longOrNull +import com.lagradost.cloudstream4.network.DesktopHttp +import okhttp3.OkHttpClient +import okhttp3.Request +import java.security.MessageDigest +import java.util.concurrent.TimeUnit + +/** A repository's repo.json, see RepositoryManager.Repository in the Android app */ +data class Repository( + val url: String, + val name: String, + val description: String?, + val pluginLists: List<String>, +) + +/** One entry in a repository's plugin list, see SitePlugin in the Android app */ +data class RepoPlugin( + val internalName: String, + val name: String, + val version: Int, + /** 0 means the provider is down, see PROVIDER_STATUS_DOWN */ + val status: Int, + val description: String?, + val authors: List<String>, + val language: String?, + val tvTypes: List<String>, + val iconUrl: String?, + /** Only set when the plugin is built with `isCrossPlatform = true` */ + val jarUrl: String?, + /** "sha256-<hex>" */ + val jarHash: String?, + val jarFileSize: Long?, + /** The Android version (.cs3), which desktop converts when there is no jar */ + val androidUrl: String? = null, + val androidHash: String? = null, + val androidFileSize: Long? = null, +) { + /** Built for desktop too, so it runs as it is */ + val supportsDesktop: Boolean get() = jarUrl != null + + /** Only built for Android, so it is converted on install */ + val needsConversion: Boolean get() = jarUrl == null && androidUrl != null + + val canInstall: Boolean get() = supportsDesktop || needsConversion +} + +class PluginDownloadException(message: String) : Exception(message) + +/** + * Downloads repositories and plugin jars. + * + * Uses OkHttp rather than java.net.http because OkHttp tries every address a host resolves to and + * races IPv6 against IPv4. The JDK clients only try the first address, so a single unreachable + * GitHub address (seen on some ISPs) made every download time out. + */ +class RepositoryClient( + // The app's client, so repository lists come from its cache while GitHub says they are fresh + private val http: OkHttpClient = DesktopHttp.client.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build(), + /** Where short codes such as "megarepo" are looked up, and "!code" ones. Changed by tests only */ + private val shortLinkHost: String = "https://cutt.ly/", + private val bangShortLinkHost: String = "https://py.md/", +) { + /** + * Turns what the user typed into a repository URL, with the same rules as the Android app's + * RepositoryManager.parseRepoUrl: cloudstreamrepo:// and https://cs.repo/ links lose their prefix, + * a URL is kept, and a short code is looked up at cutt.ly (or py.md for "!code"), which redirects + * to the repository. Returns null when nothing is found. + */ + suspend fun resolveRepositoryUrl(input: String): String? { + val text = input.trim() + // Before the URL check, which https://cs.repo/ links would otherwise pass unchanged + if (text.contains(DEEP_LINK_REGEX)) { + val rest = text.replace(DEEP_LINK_REGEX, "") + return if (rest.contains(URL_REGEX)) rest else "https://$rest" + } + if (text.contains(URL_REGEX)) return text + if (!text.matches(SHORT_CODE_REGEX)) return null + val (host, code) = if (text.startsWith("!")) bangShortLinkHost to text.removePrefix("!") else shortLinkHost to text + val location = withContext(Dispatchers.IO) { + val noRedirects = http.newBuilder().followRedirects(false).followSslRedirects(false).build() + noRedirects.newCall(Request.Builder().url(host + code).build()).execute().use { it.header("Location") } + } ?: return null + // The short link sites send unknown codes to their 404 page or their home page + val base = host.removeSuffix("/") + if (location.startsWith("$base/404") || location.removeSuffix("/") == base) return null + return location + } + + /** + * The repositories in CloudStream's community list, the same list the MegaRepo plugin adds on + * Android. Entries are either a URL or an object with a "url". + */ + suspend fun fetchCommunityRepositories(listUrl: String = COMMUNITY_REPOSITORIES): List<String> = + Json.parseToJsonElement(getText(listUrl)).jsonArray.mapNotNull { entry -> + when (entry) { + is JsonPrimitive -> entry.takeIf { it.isString }?.content + is JsonObject -> entry.string("url") + else -> null + } + }.distinct() + + suspend fun fetchRepository(url: String): Repository { + val json = Json.parseToJsonElement(getText(url)) + // Some people share the plugin list directly instead of repo.json + if (json is JsonArray) return Repository(url, url, null, listOf(url)) + val obj = json.jsonObject + return Repository( + url = url, + name = obj.string("name") ?: url, + description = obj.string("description"), + pluginLists = obj["pluginLists"]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.content }.orEmpty(), + ) + } + + /** Every plugin in all of the repository's plugin lists. A list that fails to load is skipped */ + suspend fun fetchPlugins(repository: Repository): List<RepoPlugin> = repository.pluginLists.flatMap { list -> + runCatching { parsePluginList(getText(list)) } + .onFailure { println("WARNING RepositoryClient: Could not load plugin list $list: $it") } + .getOrDefault(emptyList()) + } + + /** Downloads the plugin jar and checks its size and hash against the repository */ + suspend fun downloadJar(plugin: RepoPlugin): ByteArray { + val url = plugin.jarUrl ?: throw PluginDownloadException("${plugin.name} has no desktop version") + return getBytes(url).also { verify(plugin, it, plugin.jarFileSize, plugin.jarHash) } + } + + /** + * Downloads the plugin's Android version (.cs3) and checks its hash when the repository lists one. + * The size is not checked, because the Android app only shows it and repositories do not keep it exact. + */ + suspend fun downloadAndroidPlugin(plugin: RepoPlugin): ByteArray { + val url = plugin.androidUrl ?: throw PluginDownloadException("${plugin.name} has no Android version") + return getBytes(url).also { verify(plugin, it, size = null, hash = plugin.androidHash) } + } + + private fun verify(plugin: RepoPlugin, bytes: ByteArray, size: Long?, hash: String?) { + size?.let { expected -> + if (bytes.size.toLong() != expected) { + throw PluginDownloadException("${plugin.name}: expected $expected bytes, got ${bytes.size}") + } + } + hash?.let { expected -> + val actual = "sha256-" + MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it) } + if (!actual.equals(expected, ignoreCase = true)) { + throw PluginDownloadException("${plugin.name}: the download does not match the repository's hash") + } + } + } + + private suspend fun getText(url: String): String = getBytes(url).decodeToString() + + private suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) { + http.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw PluginDownloadException("HTTP ${response.code} for $url") + response.body.bytes() + } + } + + companion object { + const val OFFICIAL_REPOSITORY = "https://raw.githubusercontent.com/recloudstream/extensions/master/repo.json" + + /** The community repository list, see https://github.com/recloudstream/cs-repos */ + const val COMMUNITY_REPOSITORIES = "https://raw.githubusercontent.com/recloudstream/cs-repos/master/repos-db.json" + + /** + * The internal name of MegaRepo's only plugin. On Android it adds every community repository and + * nothing else, through app internals desktop does not have, so desktop does that itself instead. + */ + const val MEGA_REPO_PLUGIN = "MegaProvider" + + private val URL_REGEX = "^https?://".toRegex() + private val DEEP_LINK_REGEX = """^(cloudstreamrepo://|https://cs\.repo/\??)""".toRegex() + private val SHORT_CODE_REGEX = "^[a-zA-Z0-9!_-]+$".toRegex() + + private val INTERNAL_NAME_REGEX = "^[A-Za-z0-9._-]{1,100}$".toRegex() + + /** Letters, digits, dots, dashes and underscores, and not "." or ".." */ + fun isSafeInternalName(name: String) = INTERNAL_NAME_REGEX.matches(name) && name.trim('.').isNotEmpty() + + internal fun parsePluginList(text: String): List<RepoPlugin> = + Json.parseToJsonElement(text).jsonArray.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + val internalName = obj.string("internalName") ?: return@mapNotNull null + // It names the plugin's files: a repository must not be able to point them elsewhere + if (!isSafeInternalName(internalName)) { + println("WARNING RepositoryClient: Skipped a plugin named \"$internalName\", not a safe file name") + return@mapNotNull null + } + RepoPlugin( + internalName = internalName, + name = obj.string("name") ?: obj.string("internalName")!!, + version = obj.primitive("version")?.intOrNull ?: 0, + status = obj.primitive("status")?.intOrNull ?: 1, + description = obj.string("description"), + authors = obj["authors"].strings(), + language = obj.string("language"), + tvTypes = obj["tvTypes"].strings(), + iconUrl = obj.string("iconUrl"), + jarUrl = obj.string("jarUrl"), + jarHash = obj.string("jarHash"), + jarFileSize = obj.primitive("jarFileSize")?.longOrNull, + androidUrl = obj.string("url"), + androidHash = obj.string("fileHash"), + androidFileSize = obj.primitive("fileSize")?.longOrNull, + ) + } + + private fun JsonObject.primitive(key: String): JsonPrimitive? = this[key] as? JsonPrimitive + + private fun JsonObject.string(key: String): String? = primitive(key)?.takeIf { it.isString }?.content + + private fun JsonElement?.strings(): List<String> = + (this as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }.orEmpty() + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistory.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistory.kt new file mode 100644 index 00000000000..f82c9603525 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistory.kt @@ -0,0 +1,70 @@ +package com.lagradost.cloudstream4.search + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +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.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Search history in the Android app's key and JSON, "0/search_history/<hash of the query>", next to + * the library in the same store. + */ +class DataStoreSearchHistory( + private val context: Context = DesktopAndroid.application, + private val clock: () -> Long = { System.currentTimeMillis() }, +) : SearchHistoryStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow<Int> = _version.asStateFlow() + + private fun changed() = _version.update { it + 1 } + + /** The Android app's SearchHistoryItem */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class SearchHistoryItem( + @JsonProperty("searchedAt") val searchedAt: Long, + @JsonProperty("searchText") val searchText: String, + @JsonProperty("type") val type: List<TvType> = emptyList(), + @JsonProperty("key") val key: String, + ) + + override fun entries(): List<SearchHistoryEntry> = synchronized(this) { + context.getKeys("$ACCOUNT/$SEARCH_HISTORY") + .mapNotNull { context.getKey(it, SearchHistoryItem::class.java) } + .map { SearchHistoryEntry(it.key, it.searchText, it.searchedAt) } + .sortedByDescending { it.searchedAt } + } + + override fun add(query: String) { + if (!SearchHistoryStore.isSaved(query)) return + val key = SearchHistoryStore.keyOf(query) + synchronized(this) { context.setKey("$ACCOUNT/$SEARCH_HISTORY/$key", SearchHistoryItem(clock(), query, emptyList(), key)) } + changed() + } + + override fun remove(key: String) { + synchronized(this) { context.removeKey("$ACCOUNT/$SEARCH_HISTORY/$key") } + changed() + } + + override fun clear() { + synchronized(this) { context.getKeys("$ACCOUNT/$SEARCH_HISTORY").forEach { context.removeKey(it) } } + changed() + } + + companion object { + /** The Android app's first account, DataStoreHelper.currentAccount */ + private const val ACCOUNT = "0" + private const val SEARCH_HISTORY = "search_history" + + val instance: DataStoreSearchHistory by lazy { DataStoreSearchHistory() } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/torrent/TorrServer.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/torrent/TorrServer.kt new file mode 100644 index 00000000000..cc53a45f57a --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/torrent/TorrServer.kt @@ -0,0 +1,201 @@ +package com.lagradost.cloudstream4.torrent + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream4.AppDirs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.io.IOException +import java.net.ServerSocket +import java.net.URLEncoder +import java.security.MessageDigest +import java.util.concurrent.TimeUnit + +/** A torrent being streamed: where the player reads it, and what to drop when done */ +data class TorrentStream(val url: String, val hash: String, val fileName: String) + +/** + * Streams torrents and magnet links, as the Android app does with its built-in TorrServer. Desktop + * runs TorrServer's own Windows program (GPL-3.0, https://github.com/YouROK/TorrServer), downloaded + * once on first use and checked against its published SHA-256. It listens on this computer only, + * and the player reads the torrent from it as an HTTP stream while it downloads. + */ +class TorrServer( + private val dir: File = AppDirs.cache.resolve("torrserver").toFile(), + private val download: DownloadSource = DownloadSource.WINDOWS_AMD64, +) { + /** A pinned release of TorrServer, with the digest GitHub shows for it */ + data class DownloadSource(val url: String, val sha256: String, val fileName: String) { + companion object { + private const val VERSION = "MatriX.145.1" + val WINDOWS_AMD64 = DownloadSource( + "https://github.com/YouROK/TorrServer/releases/download/$VERSION/TorrServer-windows-amd64.exe", + "d8bf93aba0521f2450436d4ede02c3b6e44a37b52c386aa9e6206daa8e5551ed", + "TorrServer-$VERSION.exe", + ) + } + } + + private val json = jacksonObjectMapper() + private val http = OkHttpClient.Builder().readTimeout(30, TimeUnit.SECONDS).build() + private val lock = Mutex() + @Volatile private var process: Process? = null + @Volatile private var base: String? = null + + val executable: File get() = dir.resolve(download.fileName) + + /** TorrServer is downloaded already */ + val installed: Boolean get() = executable.isFile + + /** Downloads TorrServer, reporting bytes done and the full size */ + suspend fun install(progress: (Long, Long) -> Unit = { _, _ -> }) = withContext(Dispatchers.IO) { + if (installed) return@withContext + dir.mkdirs() + val part = dir.resolve("${download.fileName}.part") + val digest = MessageDigest.getInstance("SHA-256") + http.newCall(Request.Builder().url(download.url).build()).execute().use { response -> + if (!response.isSuccessful) throw IOException("TorrServer could not be downloaded: HTTP ${response.code}") + val total = response.body.contentLength() + var done = 0L + part.outputStream().use { out -> + response.body.byteStream().use { input -> + val buffer = ByteArray(256 * 1024) + while (true) { + ensureActive() + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + digest.update(buffer, 0, read) + done += read + progress(done, total) + } + } + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (actual != download.sha256) { + part.delete() + throw IOException("TorrServer's download did not match its published checksum") + } + if (!part.renameTo(executable)) throw IOException("TorrServer could not be saved to $dir") + } + + /** + * Adds a torrent or magnet link and returns where the player can stream it: the file the link + * names with `index=`, otherwise the largest video. Waits for the torrent's file list, which + * comes from its peers. + */ + suspend fun stream(link: ExtractorLink): TorrentStream = withContext(Dispatchers.IO) { + val server = start() + val added = post(server, mapOf("action" to "add", "link" to link.url, "title" to link.name, "save_to_db" to false)) + val hash = added.path("hash").asText().ifEmpty { throw IOException("TorrServer did not take the link") } + val files = withTimeoutOrNull(METADATA_TIMEOUT_MS) { files(server, hash) } ?: run { + drop(hash) + throw IOException("No peers sent the torrent's file list within a minute") + } + val wanted = Regex("""[?&]index=(\d+)""").find(link.url)?.groupValues?.get(1)?.toIntOrNull() + val file = files.firstOrNull { wanted != null && it.path("id").asInt() == wanted } + ?: files.filter { it.path("path").asText().substringAfterLast('.').lowercase() in VIDEO_EXTENSIONS }.maxByOrNull { it.path("length").asLong() } + ?: files.maxBy { it.path("length").asLong() } + val path = file.path("path").asText() + val name = URLEncoder.encode(path.substringAfterLast('/'), "UTF-8").replace("+", "%20") + TorrentStream("$server/stream/$name?link=$hash&index=${file.path("id").asInt()}&play", hash, path) + } + + /** The torrent's files, once its peers sent the list */ + private suspend fun files(server: String, hash: String): List<JsonNode> { + while (true) { + val status = post(server, mapOf("action" to "get", "hash" to hash)) + status.path("file_stats").takeIf { it.isArray && it.size() > 0 }?.let { return it.toList() } + delay(500) + } + } + + /** Stops downloading a torrent and forgets it */ + suspend fun drop(hash: String) = withContext(Dispatchers.IO) { + val server = base ?: return@withContext + runCatching { post(server, mapOf("action" to "drop", "hash" to hash)) } + runCatching { post(server, mapOf("action" to "rem", "hash" to hash)) } + } + + private fun post(server: String, body: Map<String, Any?>): JsonNode { + val request = Request.Builder().url("$server/torrents") + .post(json.writeValueAsString(body).toRequestBody("application/json".toMediaType())).build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("TorrServer: HTTP ${response.code} for ${body["action"]}") + val text = response.body.string() + return if (text.isBlank()) json.createObjectNode() else json.readTree(text) + } + } + + /** Starts TorrServer on a free port, listening on this computer only */ + private suspend fun start(): String = lock.withLock { + val running = base + if (running != null && process?.isAlive == true) return running + if (!installed) throw IOException("TorrServer is not installed") + val port = ServerSocket(0).use { it.localPort } + val data = dir.resolve("data").apply { mkdirs() } + val started = ProcessBuilder(executable.absolutePath, "-p", port.toString(), "-i", "127.0.0.1", "-d", data.absolutePath) + .directory(dir) + .redirectErrorStream(true) + .redirectOutput(dir.resolve("torrserver.log")) + .start() + process = started + val server = "http://127.0.0.1:$port" + val up = withTimeoutOrNull(START_TIMEOUT_MS) { + while (started.isAlive) { + val echo = runCatching { http.newCall(Request.Builder().url("$server/echo").build()).execute().use { it.isSuccessful } }.getOrDefault(false) + if (echo) return@withTimeoutOrNull true + delay(200) + } + false + } ?: false + if (!up) { + started.destroyForcibly() + throw IOException("TorrServer did not start, see ${dir.resolve("torrserver.log")}") + } + base = server + println("INFO TorrServer: started on $server") + server + } + + /** Stops TorrServer, also when the app exits */ + fun stop() { + val running = process ?: return + process = null + base = null + running.descendants().forEach { it.destroy() } + running.destroy() + if (!running.waitFor(3, TimeUnit.SECONDS)) running.destroyForcibly() + } + + companion object { + private const val METADATA_TIMEOUT_MS = 60_000L + private const val START_TIMEOUT_MS = 20_000L + private val VIDEO_EXTENSIONS = setOf("mkv", "mp4", "avi", "mov", "webm", "m4v", "ts", "wmv", "flv", "mpg", "mpeg") + + fun isTorrent(link: ExtractorLink) = link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.MAGNET + + /** Only on Windows for now: TorrServer publishes a program per system */ + val supported: Boolean get() = System.getProperty("os.name").orEmpty().startsWith("Windows") + + /** Whether the user agreed to stream torrents in this session, as the Android app asks */ + @Volatile var accepted = false + + val instance: TorrServer by lazy { + TorrServer().also { server -> Runtime.getRuntime().addShutdownHook(Thread { server.stop() }) } + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt new file mode 100644 index 00000000000..ad4daff0eb5 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt @@ -0,0 +1,145 @@ +package com.lagradost.cloudstream4.update + +import com.lagradost.cloudstream4.AppData +import com.lagradost.cloudstream4.AppVersion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import com.lagradost.cloudstream4.network.DesktopHttp +import okhttp3.OkHttpClient +import okhttp3.Request +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import kotlin.io.path.deleteIfExists +import kotlin.io.path.outputStream + +/** A published version of the app with its Windows installer */ +data class AppRelease( + val version: String, + val notes: String?, + val pageUrl: String, + val installerUrl: String, + val installerName: String, + /** The installer's SHA-256 as GitHub lists it, when it does */ + val sha256: String?, +) + +class AppUpdateException(message: String) : Exception(message) + +/** + * Finds newer versions of the app in the fork's GitHub releases and installs them with the release's + * MSI, which upgrades the installed app in place because every version shares one upgrade code. + */ +class AppUpdater( + private val releasesUrl: String = RELEASES, + private val http: OkHttpClient = DesktopHttp.client.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build(), +) { + /** The newest release when it is newer than this app, otherwise null. Also null when nothing is published yet */ + suspend fun findUpdate(current: String = AppVersion.current): AppRelease? { + val release = latestRelease() ?: return null + return release.takeIf { AppVersion.isNewer(it.version, current) } + } + + suspend fun latestRelease(): AppRelease? = withContext(Dispatchers.IO) { + val request = Request.Builder().url(releasesUrl).header("Accept", "application/vnd.github+json").build() + http.newCall(request).execute().use { response -> + if (response.code == 404) return@withContext null + if (!response.isSuccessful) throw AppUpdateException("GitHub answered HTTP ${response.code}") + newestRelease(response.body.string()) + } + } + + /** Downloads the installer into [dir] and checks it against the release's hash. Returns its path */ + suspend fun download(release: AppRelease, dir: Path, onProgress: (Float) -> Unit = {}): Path = withContext(Dispatchers.IO) { + Files.createDirectories(dir) + val target = dir.resolve(release.installerName) + target.deleteIfExists() + val digest = MessageDigest.getInstance("SHA-256") + http.newCall(Request.Builder().url(release.installerUrl).build()).execute().use { response -> + if (!response.isSuccessful) throw AppUpdateException("Download failed: HTTP ${response.code}") + val total = response.body.contentLength() + var done = 0L + response.body.byteStream().use { input -> + target.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + digest.update(buffer, 0, read) + done += read + if (total > 0) onProgress(done.toFloat() / total) + } + } + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (release.sha256 != null && !actual.equals(release.sha256, ignoreCase = true)) { + target.deleteIfExists() + throw AppUpdateException("The download does not match the release's SHA-256") + } + target + } + + /** + * Starts the installer and returns, the caller then quits so the installer can replace the app. + * /passive shows only a progress bar and keeps the folder the app is installed in. The app is + * installed for all users, so the installer runs as administrator: [start] starts it so, and + * throws when the user does not allow it, which leaves the app as it was. + */ + fun startInstaller( + installer: Path, + appData: AppData = AppData.instance, + start: (List<String>) -> Unit = { ProcessBuilder(it).start() }, + ) { + start(listOf("msiexec", "/i", installer.toAbsolutePath().toString(), "/passive")) + appData.markUpdating() + } + + companion object { + const val REPOSITORY = "harsh-pockt/cloudstream" + /** + * Every release, newest first. Not releases/latest, which leaves out pre-releases, and the app is + * released as pre-releases for now + */ + const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30" + + /** + * The highest version among the app's releases: tagged v<version>, with an installer, not a + * draft. The repository's other releases, such as libmpv's, are left out + */ + internal fun newestRelease(text: String): AppRelease? = + Json.parseToJsonElement(text).jsonArray.map { it.jsonObject } + .filter { (it["draft"] as? JsonPrimitive)?.content != "true" && it.string("tag_name")?.startsWith("v") == true } + .mapNotNull { parseRelease(it.toString()) } + .reduceOrNull { best, next -> if (AppVersion.isNewer(next.version, best.version)) next else best } + + internal fun parseRelease(text: String): AppRelease? { + val obj = Json.parseToJsonElement(text).jsonObject + val tag = obj.string("tag_name") ?: return null + val installer = obj["assets"]?.jsonArray?.map { it.jsonObject } + ?.firstOrNull { it.string("name")?.endsWith(".msi", ignoreCase = true) == true } + ?: return null + return AppRelease( + version = tag.removePrefix("v"), + notes = obj.string("body"), + pageUrl = obj.string("html_url") ?: "https://github.com/$REPOSITORY/releases", + installerUrl = installer.string("browser_download_url") ?: return null, + installerName = installer.string("name")!!, + // GitHub lists asset digests as "sha256:<hex>" + sha256 = installer.string("digest")?.takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"), + ) + } + + private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + } +} diff --git a/shared/src/jvmMain/kotlin/com/mihon/common/preference/JvmPreferenceStore.kt b/shared/src/jvmMain/kotlin/com/mihon/common/preference/JvmPreferenceStore.kt new file mode 100644 index 00000000000..734b233fb39 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/mihon/common/preference/JvmPreferenceStore.kt @@ -0,0 +1,206 @@ +package com.mihon.common.preference + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * Desktop implementation of PreferenceStore, keeping every preference in a single JSON file. + * + * Every write is saved right away by writing a temporary file and moving it over the old one, + * so a crash never leaves a half written file behind. A missing or unreadable file means defaults. + */ +class JvmPreferenceStore(private val file: Path) : PreferenceStore { + private val lock = Any() + private val values: MutableMap<String, JsonElement> = load(file) + private val keyFlow = MutableSharedFlow<String?>( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + internal fun read(key: String): JsonElement? = synchronized(lock) { values[key] } + + internal fun contains(key: String): Boolean = synchronized(lock) { values.containsKey(key) } + + /** Writes or, with a null value, removes a key and saves the file */ + internal fun write(key: String, value: JsonElement?) { + synchronized(lock) { + if (value == null) values.remove(key) else values[key] = value + save() + } + keyFlow.tryEmit(key) + } + + private fun save() { + file.parent?.createDirectories() + val tmp = file.resolveSibling("${file.fileName}.tmp") + tmp.writeText(JsonObject(values.toMap()).toString()) + Files.move(tmp, file, REPLACE_EXISTING, ATOMIC_MOVE) + } + + private fun <T> preference( + key: String, + defaultValue: T, + decode: (JsonElement) -> T?, + encode: (T) -> JsonElement, + ): PreferenceData<T> = JvmPreference(this, keyFlow, key, defaultValue, decode, encode) + + override fun getString(key: String, defaultValue: String): PreferenceData<String> = + preference(key, defaultValue, { it.stringOrNull() }, ::JsonPrimitive) + + override fun getLong(key: String, defaultValue: Long): PreferenceData<Long> = + preference(key, defaultValue, { it.jsonPrimitive.longOrNull }, ::JsonPrimitive) + + override fun getInt(key: String, defaultValue: Int): PreferenceData<Int> = + preference(key, defaultValue, { it.jsonPrimitive.intOrNull }, ::JsonPrimitive) + + override fun getFloat(key: String, defaultValue: Float): PreferenceData<Float> = + preference(key, defaultValue, { it.jsonPrimitive.floatOrNull }, ::JsonPrimitive) + + override fun getBoolean(key: String, defaultValue: Boolean): PreferenceData<Boolean> = + preference(key, defaultValue, { it.jsonPrimitive.booleanOrNull }, ::JsonPrimitive) + + override fun getStringSet(key: String, defaultValue: Set<String>): PreferenceData<Set<String>> = + preference(key, defaultValue, { it.stringSet() }, { it.toJsonArray() }) + + override fun <T> getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): PreferenceData<T> = preference( + key, + defaultValue, + { it.stringOrNull()?.let(deserializer) }, + { JsonPrimitive(serializer(it)) }, + ) + + override fun <T> getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): PreferenceData<T> = preference( + key, + defaultValue, + { it.jsonPrimitive.intOrNull?.let(deserializer) }, + { JsonPrimitive(serializer(it)) }, + ) + + override fun <T> getObjectSetFromStringSet( + key: String, + defaultValue: Set<T>, + serializer: (T) -> String, + deserializer: (String) -> T?, + ): PreferenceData<Set<T>> = preference( + key, + defaultValue, + { it.stringSet().mapNotNull(deserializer).toSet() }, + { set -> set.map(serializer).toSet().toJsonArray() }, + ) + + override fun getAll(): Map<String, *> = synchronized(lock) { + values.mapValues { (_, value) -> + when (value) { + is JsonArray -> value.stringSet() + is JsonPrimitive -> value.stringOrNull() + ?: value.booleanOrNull + ?: value.longOrNull + ?: value.doubleOrNull + else -> null + } + } + } + + private companion object { + fun load(file: Path): MutableMap<String, JsonElement> { + if (!file.exists()) return mutableMapOf() + return try { + Json.parseToJsonElement(file.readText()).jsonObject.toMutableMap() + } catch (t: Throwable) { + // Keep the unreadable file for debugging instead of silently overwriting it on the next write + println("ERROR JvmPreferenceStore: Could not read $file, using defaults: $t") + runCatching { Files.copy(file, file.resolveSibling("${file.fileName}.corrupt"), REPLACE_EXISTING) } + mutableMapOf() + } + } + + fun JsonElement.stringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + + fun JsonElement.stringSet(): Set<String> = + jsonArray.mapNotNull { it.stringOrNull() }.toSet() + + fun Set<String>.toJsonArray(): JsonArray = JsonArray(map(::JsonPrimitive)) + } +} + +class JvmPreference<T>( + private val store: JvmPreferenceStore, + private val keyFlow: Flow<String?>, + private val key: String, + private val defaultValue: T, + private val decode: (JsonElement) -> T?, + private val encode: (T) -> JsonElement, +) : PreferenceData<T> { + override fun key(): String = key + + /** A value of the wrong type, or one the deserializer rejects, reads as the default */ + override fun get(): T { + val stored = store.read(key) ?: return defaultValue + return runCatching { decode(stored) }.getOrNull() ?: defaultValue + } + + override fun set(value: T) { + store.write(key, encode(value)) + } + + override fun isSet(): Boolean = store.contains(key) + + override fun delete() { + store.write(key, null) + } + + override fun defaultValue(): T = defaultValue + + override fun changes(): Flow<T> { + return keyFlow + .filter { it == key || it == null } + .onStart { emit("ignition") } + .map { get() } + .conflate() + } + + override fun stateIn(scope: CoroutineScope): StateFlow<T> { + return changes().stateIn(scope, SharingStarted.Eagerly, get()) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt new file mode 100644 index 00000000000..1ce696c3086 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt @@ -0,0 +1,150 @@ +package com.lagradost.cloudstream4 + +import com.lagradost.cloudstream4.update.AppUpdater +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AppDataTest { + private val dir: Path = Files.createTempDirectory("cs-appdata") + private val root = dir.resolve("root") + private val cache = dir.resolve("cache") + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + private fun file(path: Path) = path.also { it.parent.createDirectories(); it.writeText("x") } + + private fun fillData() { + file(root.resolve("settings.json")) + file(root.resolve("plugins/A.jar")) + file(root.resolve("android/shared_prefs/a.json")) + file(root.resolve("android/shared_prefs/rebuild_preference.json")) + file(root.resolve("android/cache/c")) + file(cache.resolve("posters/p.jpg")) + } + + @Test + fun eachResetDeletesOnlyItsPart() { + fillData() + val data = AppData(root, cache, installedExecutable = null) + + data.reset(AppData.Reset.CACHE) + assertFalse(cache.resolve("posters").exists()) + assertFalse(root.resolve("android/cache/c").exists()) + assertTrue(root.resolve("plugins/A.jar").exists()) + + data.reset(AppData.Reset.EXTENSIONS) + assertFalse(root.resolve("plugins").exists()) + assertFalse(root.resolve("android/shared_prefs/a.json").exists()) + assertTrue(root.resolve("android/shared_prefs/rebuild_preference.json").exists(), "The library stays") + assertTrue(root.resolve("settings.json").exists()) + + data.reset(AppData.Reset.EVERYTHING) + assertFalse(data.hasData()) + } + + @Test + fun scheduledResetRunsOnTheNextStartOnly() { + fillData() + val data = AppData(root, cache, installedExecutable = null) + data.scheduleReset(AppData.Reset.EVERYTHING) + assertTrue(root.resolve("settings.json").exists()) + + data.runPendingReset() + assertFalse(root.resolve("settings.json").exists()) + file(root.resolve("settings.json")) + data.runPendingReset() + assertTrue(root.resolve("settings.json").exists(), "A reset runs once") + } + + @Test + fun reinstallIsNoticedButAnUpdateThroughTheAppIsNot() { + fillData() + val exe = file(dir.resolve("install1/CloudStream.exe")) + val first = AppData(root, cache, exe) + assertFalse(first.isReinstallWithOldData(), "The first run only notes the install") + first.rememberInstall() + assertFalse(AppData(root, cache, exe).isReinstallWithOldData(), "Same install") + + // Installing again creates the executable again, with a new creation time + Thread.sleep(20) + val reinstalled = file(dir.resolve("install2/CloudStream.exe")) + assertTrue(AppData(root, cache, reinstalled).isReinstallWithOldData()) + + first.markUpdating() + val updated = AppData(root, cache, reinstalled) + assertFalse(updated.isReinstallWithOldData(), "An update the app started") + updated.rememberInstall() + assertFalse(AppData(root, cache, reinstalled).isReinstallWithOldData()) + } + + @Test + fun reinstallWithoutDataDoesNotAsk() { + val exe = file(dir.resolve("install1/CloudStream.exe")) + AppData(root, cache, exe).rememberInstall() + Thread.sleep(20) + val reinstalled = file(dir.resolve("install2/CloudStream.exe")) + assertFalse(AppData(root, cache, reinstalled).isReinstallWithOldData()) + } + + @Test + fun versionsCompareNumberByNumber() { + assertTrue(AppVersion.isNewer("4.10.0", "4.9.2")) + assertTrue(AppVersion.isNewer("v4.8.1", "4.8.0")) + assertFalse(AppVersion.isNewer("4.8.0", "4.8.0")) + assertFalse(AppVersion.isNewer("4.7.9", "4.8")) + } + + @Test + fun aPreReleaseComesBeforeItsVersion() { + // The installed beta.1 says 4.8.0: offering beta.1 again would install it over and over + assertFalse(AppVersion.isNewer("4.8.0-beta.1", "4.8.0")) + assertTrue(AppVersion.isNewer("4.8.0", "4.8.0-beta.1")) + assertFalse(AppVersion.isNewer("4.8.0-beta.1", "4.8.0-beta.1")) + assertTrue(AppVersion.isNewer("v4.8.0-beta.2", "4.8.0-beta.1")) + assertTrue(AppVersion.isNewer("4.8.0-beta.10", "4.8.0-beta.9")) + assertTrue(AppVersion.isNewer("4.8.0-rc.1", "4.8.0-beta.3")) + assertTrue(AppVersion.isNewer("4.8.1-beta.1", "4.8.0")) + assertFalse(AppVersion.isNewer("4.8.0-beta", "4.8.0-beta.1")) + } + + @Test + fun releaseIsReadFromGitHub() { + val release = AppUpdater.parseRelease( + """{"tag_name":"v4.9.0","html_url":"https://github.com/r/releases/tag/v4.9.0","body":"Notes", + "assets":[{"name":"libmpv-source.zip","browser_download_url":"https://x/src.zip"}, + {"name":"CloudStream-4.9.0.msi","browser_download_url":"https://x/c.msi","digest":"sha256:abc123"}]}""" + )!! + assertEquals("4.9.0", release.version) + assertEquals("https://x/c.msi", release.installerUrl) + assertEquals("abc123", release.sha256) + assertNull(AppUpdater.parseRelease("""{"tag_name":"v1","assets":[]}"""), "No installer, no update") + } + + @Test + fun theNewestAppReleaseIsPickedIncludingPreReleases() { + fun release(tag: String, prerelease: Boolean = true, draft: Boolean = false, msi: Boolean = true) = + """{"tag_name":"$tag","prerelease":$prerelease,"draft":$draft,"html_url":"https://x/$tag","assets":[""" + + (if (msi) """{"name":"CloudStream.msi","browser_download_url":"https://x/$tag.msi"}""" else "") + "]}" + val list = listOf( + release("libmpv-20261003-git-3186d369f9"), + release("v4.10.0", draft = true), + release("v4.8.1", prerelease = false), + release("v4.9.0"), + release("v4.9.1", msi = false), + ).joinToString(",", "[", "]") + assertEquals("4.9.0", AppUpdater.newestRelease(list)?.version) + assertNull(AppUpdater.newestRelease("[]")) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt new file mode 100644 index 00000000000..0ab9a5cc76b --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt @@ -0,0 +1,288 @@ +package com.lagradost.cloudstream4 + +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mainPageOf +import com.lagradost.cloudstream3.newHomePageResponse +import com.lagradost.cloudstream3.newMovieSearchResponse +import com.lagradost.cloudstream3.newSearchResponseList +import com.lagradost.cloudstream4.home.HomeAction +import com.lagradost.cloudstream4.home.HomeStatus +import com.lagradost.cloudstream4.home.HomeViewModel +import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.plugins.RepositoryClient +import com.lagradost.cloudstream4.providers.filterProviders +import com.lagradost.cloudstream4.search.InMemorySearchHistory +import com.lagradost.cloudstream4.search.ProviderStatus +import com.lagradost.cloudstream4.search.SearchAction +import com.lagradost.cloudstream4.search.SearchViewModel +import com.mihon.common.preference.JvmPreferenceStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** A provider that answers from memory, optionally waiting for [gate] first or failing */ +private class FakeProvider( + override var name: String, + override var lang: String = "en", + override val supportedTypes: Set<TvType> = setOf(TvType.Movie), + override val hasMainPage: Boolean = true, + private val pages: Int = 1, + private val fail: Boolean = false, +) : MainAPI() { + override var mainUrl = "https://example.invalid" + override val mainPage = mainPageOf("popular" to "Popular", "new" to "New", "empty" to "Empty") + var gate: CompletableDeferred<Unit>? = null + val searchedPages = mutableListOf<Int>() + + fun item(title: String, quality: SearchQuality? = null) = + newMovieSearchResponse(title, "https://example.invalid/$title") { this.quality = quality } + + override suspend fun search(query: String, page: Int): SearchResponseList { + gate?.await() + if (fail) throw IllegalStateException("site is down") + searchedPages += page + return newSearchResponseList( + listOf(item("$name $query $page"), item("$name cam $page", SearchQuality.Cam)), + hasNext = page < pages, + ) + } + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + gate?.await() + if (fail) throw IllegalStateException("site is down") + val items = if (request.data == "empty") emptyList() else listOf(item("$name ${request.name}")) + return newHomePageResponse(HomePageList(request.name, items, isHorizontalImages = request.data == "new")) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class BrowseViewModelTest { + private val dir = Files.createTempDirectory("cs-browse") + private val settings = AppSettings(JvmPreferenceStore(dir.resolve("settings.json"))) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + dir.toFile().deleteRecursively() + } + + /** Waits for real provider calls, which run on the worker threads */ + private fun <T> awaitState(flow: kotlinx.coroutines.flow.StateFlow<T>, predicate: (T) -> Boolean): T = + runBlocking { withTimeout(5_000) { flow.first(predicate) } } + + @Test + fun providerFilterFollowsLanguageMediaAndHomePageSettings() { + val en = FakeProvider("En") + val hi = FakeProvider("Hi", lang = "hi") + val anime = FakeProvider("Anime", supportedTypes = setOf(TvType.Anime)) + val noHome = FakeProvider("NoHome", hasMainPage = false) + val all = listOf(en, hi, anime, noHome) + val movie = setOf(TvType.Movie.ordinal.toString()) + + assertEquals(all, filterProviders(all, setOf(AllLanguagesName), emptySet(), requireMainPage = false)) + assertEquals(listOf(hi), filterProviders(all, setOf("hi"), emptySet(), requireMainPage = false)) + assertEquals(listOf(en, hi, noHome), filterProviders(all, setOf(AllLanguagesName), movie, requireMainPage = false)) + assertEquals(listOf(en, hi, anime), filterProviders(all, setOf(AllLanguagesName), emptySet(), requireMainPage = true)) + } + + @Test + fun searchFillsEachProviderAndKeepsFailuresSeparate() { + val good = FakeProvider("Good") + val broken = FakeProvider("Broken", fail = true) + val vm = SearchViewModel({ listOf(good, broken) }, settings) + + vm.onAction(SearchAction.QueryChanged(" dune ")) + vm.onAction(SearchAction.Submit) + val state = awaitState(vm.state) { !it.isSearching } + + assertEquals("dune", state.searchedQuery) + val (goodRow, brokenRow) = state.results + assertEquals(ProviderStatus.Done, goodRow.status) + assertEquals(listOf("Good dune 1", "Good cam 1"), goodRow.items.map { it.name }) + assertIs<ProviderStatus.Failed>(brokenRow.status) + assertTrue(brokenRow.items.isEmpty()) + } + + @Test + fun hiddenQualitiesAreFilteredAndLoadMoreAppendsTheNextPage() { + settings.ui.filterQuality.set(setOf(SearchQuality.Cam)) + val paged = FakeProvider("Paged", pages = 2) + val vm = SearchViewModel({ listOf(paged) }, settings) + + vm.onAction(SearchAction.QueryChanged("x")) + vm.onAction(SearchAction.Submit) + val first = awaitState(vm.state) { !it.isSearching }.results.single() + assertEquals(listOf("Paged x 1"), first.items.map { it.name }) + assertTrue(first.hasNext) + + vm.onAction(SearchAction.LoadMore("Paged")) + val second = awaitState(vm.state) { it.results.single().page == 2 }.results.single() + assertEquals(listOf("Paged x 1", "Paged x 2"), second.items.map { it.name }) + assertEquals(false, second.hasNext) + + // Nothing more to load, so no request is made + vm.onAction(SearchAction.LoadMore("Paged")) + assertEquals(listOf(1, 2), paged.searchedPages) + } + + @Test + fun aNewSearchDropsLateAnswersFromTheOldOne() { + val slow = FakeProvider("Slow").apply { gate = CompletableDeferred() } + val vm = SearchViewModel({ listOf(slow) }, settings) + + vm.onAction(SearchAction.QueryChanged("old")) + vm.onAction(SearchAction.Submit) + val oldGate = slow.gate!! + slow.gate = null + vm.onAction(SearchAction.QueryChanged("new")) + vm.onAction(SearchAction.Submit) + oldGate.complete(Unit) + + val state = awaitState(vm.state) { !it.isSearching } + assertEquals(listOf("Slow new 1", "Slow cam 1"), state.results.single().items.map { it.name }) + } + + @Test + fun searchesAreRememberedNewestFirstAndCanBeRunAgain() { + var now = 0L + val history = InMemorySearchHistory { ++now } + val provider = FakeProvider("P") + val vm = SearchViewModel({ listOf(provider) }, settings, history) + + listOf("dune", "x", "alien", "dune").forEach { + vm.onAction(SearchAction.QueryChanged(it)) + vm.onAction(SearchAction.Submit) + } + // One letter is not saved, and searching "dune" again moves it to the top + assertEquals(listOf("dune", "alien"), vm.state.value.history.map { it.query }) + + vm.onAction(SearchAction.Clear) + assertEquals(listOf("dune", "alien"), vm.state.value.history.map { it.query }, "clearing the box keeps the history") + + vm.onAction(SearchAction.SearchFor("alien")) + val state = awaitState(vm.state) { !it.isSearching && it.searchedQuery == "alien" } + assertEquals("alien", state.query) + assertEquals(listOf("alien", "dune"), state.history.map { it.query }) + + vm.onAction(SearchAction.RemoveHistory(state.history.first().key)) + assertEquals(listOf("dune"), vm.state.value.history.map { it.query }) + vm.onAction(SearchAction.ClearHistory) + assertTrue(vm.state.value.history.isEmpty()) + } + + @Test + fun searchWithNoEnabledProvidersSaysSo() { + settings.provider.extensionLanguages.set(setOf("fr")) + val vm = SearchViewModel({ listOf(FakeProvider("En")) }, settings) + vm.onAction(SearchAction.QueryChanged("x")) + vm.onAction(SearchAction.Submit) + assertTrue(vm.state.value.noProviders) + } + + @Test + fun homeShowsTheFirstProviderAndDropsEmptyRows() { + val vm = HomeViewModel({ listOf(FakeProvider("A"), FakeProvider("B")) }, settings) + val state = awaitState(vm.state) { it.status == HomeStatus.Done } + + assertEquals(listOf("A", "B"), state.providers) + assertEquals("A", state.selected) + assertEquals(listOf("Popular", "New"), state.rows.map { it.name }) + assertEquals(listOf(false, true), state.rows.map { it.horizontalImages }) + } + + @Test + fun theChosenHomeProviderIsSavedAndUsedNextTime() { + val providers = listOf(FakeProvider("A"), FakeProvider("B")) + val vm = HomeViewModel({ providers }, settings) + vm.onAction(HomeAction.SelectProvider("B")) + awaitState(vm.state) { it.selected == "B" && it.status == HomeStatus.Done } + + val restarted = AppSettings(JvmPreferenceStore(dir.resolve("settings.json"))) + val next = HomeViewModel({ providers }, restarted) + assertEquals("B", awaitState(next.state) { it.status == HomeStatus.Done }.selected) + } + + @Test + fun homeWaitsForProvidersThenFallsBackWhenTheShownOneIsRemoved() { + var providers = emptyList<MainAPI>() + val vm = HomeViewModel({ providers }, settings) + assertEquals(HomeStatus.NoProviders, vm.state.value.status) + + // Plugins finish loading after the screen opened + providers = listOf(FakeProvider("A"), FakeProvider("B")) + vm.onAction(HomeAction.SelectProvider("B")) // not known yet, ignored + vm.onAction(HomeAction.ProvidersChanged) + awaitState(vm.state) { it.selected == "A" && it.status == HomeStatus.Done } + vm.onAction(HomeAction.SelectProvider("B")) + awaitState(vm.state) { it.selected == "B" && it.status == HomeStatus.Done } + + providers = providers.take(1) + vm.onAction(HomeAction.ProvidersChanged) + assertEquals("A", awaitState(vm.state) { it.status == HomeStatus.Done && it.selected == "A" }.selected) + } + + /** + * Installs a real plugin from the official repository, then browses its home page and searches it. + * Needs the internet, so it only runs with CLOUDSTREAM_ONLINE_TESTS=1. + */ + @Test + fun officialPluginWorksInHomeAndSearch() { + if (System.getenv("CLOUDSTREAM_ONLINE_TESTS") != "1") return println("Skipped, set CLOUDSTREAM_ONLINE_TESTS=1") + val client = RepositoryClient() + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + val plugin = runBlocking { + val repo = client.fetchRepository(RepositoryClient.OFFICIAL_REPOSITORY) + client.fetchPlugins(repo).first { it.internalName == "InternetArchiveProvider" }.also { + manager.install(repo.url, it) + } + } + val providers = manager.loadedPlugin(plugin.internalName)!!.providers + try { + val home = HomeViewModel({ providers }, settings) + val homeState = runBlocking { withTimeout(60_000) { home.state.first { it.status !is HomeStatus.Loading } } } + println("Home ${homeState.selected}: ${homeState.status}, rows ${homeState.rows.map { "${it.name} (${it.items.size})" }}") + assertEquals(HomeStatus.Done, homeState.status) + assertTrue(homeState.rows.isNotEmpty()) + + val search = SearchViewModel({ providers }, settings) + search.onAction(SearchAction.QueryChanged("nosferatu")) + search.onAction(SearchAction.Submit) + val row = runBlocking { withTimeout(60_000) { search.state.first { !it.isSearching } } }.results.single() + println("Search ${row.apiName}: ${row.status}, ${row.items.size} results, first ${row.items.firstOrNull()?.name}") + assertTrue(row.items.isNotEmpty()) + } finally { + runBlocking { manager.uninstall(plugin.internalName) } + } + } + + @Test + fun aFailingHomePageShowsTheErrorAndCanBeRetried() { + val vm = HomeViewModel({ listOf(FakeProvider("Down", fail = true)) }, settings) + assertIs<HomeStatus.Failed>(awaitState(vm.state) { it.status is HomeStatus.Failed }.status) + vm.onAction(HomeAction.Reload) + assertIs<HomeStatus.Failed>(awaitState(vm.state) { it.status is HomeStatus.Failed }.status) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt new file mode 100644 index 00000000000..8235280cae6 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt @@ -0,0 +1,401 @@ +package com.lagradost.cloudstream4 + +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.addEpisodes +import com.lagradost.cloudstream3.addSeasonNames +import com.lagradost.cloudstream3.newAnimeLoadResponse +import com.lagradost.cloudstream3.newEpisode +import com.lagradost.cloudstream3.newMovieLoadResponse +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.newTorrentLoadResponse +import com.lagradost.cloudstream3.newTvSeriesLoadResponse +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream4.detail.DetailAction +import com.lagradost.cloudstream4.detail.DetailStatus +import com.lagradost.cloudstream4.detail.DetailViewModel +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.library.InMemoryWatchStore +import com.lagradost.cloudstream4.library.SavedSource +import com.lagradost.cloudstream4.library.WatchType +import com.lagradost.cloudstream4.library.saveProgress +import com.lagradost.cloudstream4.player.HumanChecks +import com.lagradost.cloudstream4.player.LinksAction +import com.lagradost.cloudstream4.player.LinksViewModel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** Serves a movie, a series, an anime and a torrent, and links for each, from memory */ +private class FakeCatalog : MainAPI() { + override var name = "Catalog" + override var mainUrl = "https://example.invalid" + + /** loadLinks waits for this before sending the links listed after [slowAfter] */ + var gate: CompletableDeferred<Unit>? = null + var slowAfter = Int.MAX_VALUE + var links: List<Pair<String, Int>> = emptyList() + var torrents = 0 + /** Runs as links load, like an extractor whose site asks for a check */ + var duringLinks: (suspend () -> Unit)? = null + + private fun ep(data: String, season: Int?, number: Int, title: String? = null) = + newEpisode(data, initializer = { this.season = season; this.episode = number; this.name = title }, fix = false) + + override suspend fun load(url: String): LoadResponse = when (url.substringAfterLast('/')) { + "movie" -> newMovieLoadResponse("A movie", url, TvType.Movie, "movie-data") { plot = "Plot"; tags = listOf("Drama", " ") } + "series" -> newTvSeriesLoadResponse( + "A series", url, TvType.TvSeries, + listOf(ep("s2e1", 2, 1), ep("s1e1", 1, 1, "Pilot"), ep("s1e2", 1, 2), ep("extra", null, 1)), + ) { addSeasonNames(listOf(SeasonData(1, "The beginning"), SeasonData(2, displaySeason = 20))) } + + "anime" -> newAnimeLoadResponse("An anime", url, TvType.Anime) { + addEpisodes(DubStatus.Subbed, listOf(ep("sub1", null, 1), ep("sub2", null, 2))) + addEpisodes(DubStatus.Dubbed, listOf(ep("dub1", null, 1))) + } + + "torrent" -> newTorrentLoadResponse("A torrent", url, magnet = "magnet:?xt=x") + else -> throw IllegalStateException("not found") + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ): Boolean { + duringLinks?.invoke() + subtitleCallback(newSubtitleFile("English", "https://example.invalid/en.vtt")) + subtitleCallback(newSubtitleFile("English", "https://example.invalid/en.vtt")) + repeat(torrents) { callback(newExtractorLink("T", "Torrent", "magnet:?xt=$it", ExtractorLinkType.MAGNET)) } + links.forEachIndexed { index, (name, quality) -> + if (index == slowAfter) gate?.await() + callback(newExtractorLink(name, name, "https://example.invalid/$name.m3u8", ExtractorLinkType.M3U8) { this.quality = quality }) + } + return links.isNotEmpty() + } +} + +/** Sites that ask for a check, and the ones the user passed */ +private class FakeChecks : HumanChecks { + val sites = MutableSharedFlow<String>(extraBufferCapacity = 8) + val passed = mutableListOf<String>() + override val asked = sites + + override suspend fun pass(host: String): Boolean { + passed += host + return true + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaybackViewModelTest { + private val api = FakeCatalog() + + // Not a test dispatcher: the links view model waits in real time before picking a link + @BeforeTest + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun <T> await(flow: StateFlow<T>, predicate: (T) -> Boolean): T = + runBlocking { withTimeout(5_000) { flow.first(predicate) } } + + @Test + fun movieGetsAPlayRequestAndBlankTagsAreDropped() { + val vm = DetailViewModel(api, "https://example.invalid/movie") + val state = await(vm.state) { it.status == DetailStatus.Done } + assertEquals("movie-data", state.movieData) + assertEquals(listOf("Drama"), state.response!!.tags) + assertTrue(state.seasons.isEmpty()) + assertEquals(PlayRequest("Catalog", "A movie", null, "movie-data"), vm.playRequest()!!.copy(tracking = null)) + } + + @Test + fun seriesEpisodesAreGroupedBySeasonWithTheirNames() { + val vm = DetailViewModel(api, "https://example.invalid/series") + val state = await(vm.state) { it.status == DetailStatus.Done } + assertEquals(listOf("The beginning", "Season 20", "Other"), state.seasons.map { it.label }) + assertEquals(listOf("s1e1", "s1e2"), state.seasons[0].episodes.map { it.data }) + + val pilot = state.seasons[0].episodes[0] + assertEquals(PlayRequest("Catalog", "A series", "S1 E1 · Pilot", "s1e1"), vm.playRequest(pilot)!!.copy(tracking = null)) + + vm.onAction(DetailAction.SelectSeason(1)) + assertEquals(1, vm.state.value.selectedSeason) + vm.onAction(DetailAction.SelectSeason(99)) + assertEquals(2, vm.state.value.selectedSeason) + } + + @Test + fun animeEpisodesCanBeSwitchedBetweenSubbedAndDubbed() { + val vm = DetailViewModel(api, "https://example.invalid/anime") + val state = await(vm.state) { it.status == DetailStatus.Done } + assertEquals(listOf(DubStatus.Subbed, DubStatus.Dubbed), state.dubs) + assertEquals(listOf("sub1", "sub2"), state.seasons.single().episodes.map { it.data }) + assertEquals("Episodes", state.seasons.single().label) + + vm.onAction(DetailAction.SelectDub(DubStatus.Dubbed)) + assertEquals(listOf("dub1"), vm.state.value.seasons.single().episodes.map { it.data }) + assertEquals("E1", DetailViewModel.episodeLabel(vm.state.value.seasons.single().episodes.single())) + } + + @Test + fun torrentsAreMarkedUnsupportedAndFailuresCanBeRetried() { + val torrent = await(DetailViewModel(api, "https://example.invalid/torrent").state) { it.status == DetailStatus.Done } + assertTrue(torrent.unsupported != null) + + val missing = DetailViewModel(api, "https://example.invalid/missing") + assertIs<DetailStatus.Failed>(await(missing.state) { it.status is DetailStatus.Failed }.status) + missing.onAction(DetailAction.Retry) + assertIs<DetailStatus.Failed>(await(missing.state) { it.status is DetailStatus.Failed }.status) + } + + private fun links(vararg links: Pair<String, Int>, autoPickMs: Long = 50): LinksViewModel { + api.links = links.toList() + return LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data"), autoPickMs.milliseconds) + } + + @Test + fun theBestLinkIsPickedAndTorrentsAreSkipped() { + api.torrents = 2 + val vm = links("low" to Qualities.P360.value, "unknown" to Qualities.Unknown.value, "hd" to Qualities.P1080.value, "sd" to Qualities.P720.value) + val state = await(vm.state) { !it.loading } + + assertEquals(listOf("hd", "sd", "unknown", "low"), state.links.map { it.name }) + assertEquals("hd", state.selectedLink?.name) + assertEquals(2, state.skipped) + assertEquals(1, state.subtitles.size) + assertEquals("hd · 1080p", LinksViewModel.label(state.links[0])) + } + + @Test + fun whereTorrentsCanPlayTheyComeAfterEveryOtherLink() { + api.torrents = 2 + api.links = listOf("low" to Qualities.P360.value) + val vm = LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data"), 50.milliseconds, torrents = true) + val state = await(vm.state) { !it.loading } + + assertEquals(listOf("low", "Torrent", "Torrent"), state.links.map { it.name }) + assertEquals("low", state.selectedLink?.name) + assertEquals(0, state.skipped) + + val title = await(DetailViewModel(api, "https://example.invalid/torrent", torrents = true).state) { it.status == DetailStatus.Done } + assertNull(title.unsupported) + assertEquals("magnet:?xt=x", title.movieData) + // The title's magnet plays even when the extension gives no link for it + api.torrents = 0 + api.links = emptyList() + val magnet = LinksViewModel(api, PlayRequest("Catalog", "A torrent", null, "magnet:?xt=x"), 50.milliseconds, torrents = true) + assertEquals("magnet:?xt=x", await(magnet.state) { !it.loading }.selectedLink?.url) + } + + @Test + fun aFailedLinkMovesOnToTheNextUntilNoneAreLeft() { + val vm = links("a" to 1080, "b" to 720) + await(vm.state) { !it.loading } + + vm.onAction(LinksAction.Failed("https://example.invalid/a.m3u8", "403")) + assertEquals("b", vm.state.value.selectedLink?.name) + // A late failure report for a link no longer playing changes nothing + vm.onAction(LinksAction.Failed("https://example.invalid/a.m3u8", "403")) + assertEquals("b", vm.state.value.selectedLink?.name) + + vm.onAction(LinksAction.Failed("https://example.invalid/b.m3u8", "timeout")) + assertNull(vm.state.value.selected) + assertTrue(vm.state.value.exhausted) + + vm.onAction(LinksAction.Select("https://example.invalid/a.m3u8")) + assertEquals("a", vm.state.value.selectedLink?.name) + } + + private fun preferring(source: SavedSource, vararg links: Pair<String, Int>): LinksViewModel { + api.links = links.toList() + // Long enough that only the saved source can be picked before loading ends + return LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data", preferredSource = source), 10.seconds) + } + + @Test + fun theSourceLastWatchedPlaysAsSoonAsItComes() { + api.gate = CompletableDeferred() + api.slowAfter = 2 + val vm = preferring(SavedSource("sd", 720), "hd" to 1080, "sd" to 720, "late" to 2160) + + // Picked while links are still coming, over a better one + val early = await(vm.state) { it.selected != null } + assertTrue(early.loading) + assertEquals("sd", early.selectedLink?.name) + api.gate!!.complete(Unit) + assertEquals("sd", await(vm.state) { !it.loading }.selectedLink?.name) + + // When it fails, the best of the rest plays + vm.onAction(LinksAction.Failed("https://example.invalid/sd.m3u8", "403")) + assertEquals("late", vm.state.value.selectedLink?.name) + } + + @Test + fun theSourceLastWatchedComesFirstInAnotherQuality() { + val vm = preferring(SavedSource("sd", 480), "hd" to 1080, "sd" to 720) + assertEquals("sd", await(vm.state) { !it.loading }.selectedLink?.name) + } + + @Test + fun aSlowExtractorDoesNotHoldBackPlayback() { + api.gate = CompletableDeferred() + api.slowAfter = 1 + val vm = links("fast" to 480, "slow" to 1080) + + // The fast link plays while the slow extractor is still working + val early = await(vm.state) { it.selected != null } + assertTrue(early.loading) + assertEquals("fast", early.selectedLink?.name) + + api.gate!!.complete(Unit) + val done = await(vm.state) { !it.loading } + assertEquals(listOf("slow", "fast"), done.links.map { it.name }) + // The better link arriving later does not interrupt what is playing + assertEquals("fast", done.selectedLink?.name) + } + + @Test + fun sitesThatWantACheckComeLastAndAreOnlyPassedWhenAsked() { + val checks = FakeChecks() + api.links = listOf("a" to 1080) + api.duringLinks = { checks.sites.emit("guarded.example") } + val vm = LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data"), 50.milliseconds, checks = checks) + + // The other links play first, and nothing is asked of the user + val first = await(vm.state) { !it.loading } + assertEquals("a", first.selectedLink?.name) + assertEquals(listOf("guarded.example"), first.checkSites) + assertTrue(checks.passed.isEmpty()) + vm.onAction(LinksAction.Failed("https://example.invalid/a.m3u8", "403")) + assertTrue(vm.state.value.exhausted) + + // Once nothing else plays, the user passes the check, and the site's links come + api.duringLinks = null + api.links = listOf("a" to 1080, "guarded" to 720) + vm.onAction(LinksAction.PassChecks) + val after = await(vm.state) { !it.loading && it.links.size == 2 } + assertEquals(listOf("guarded.example"), checks.passed) + assertTrue(after.checkSites.isEmpty()) + // The link that failed before is not tried again + assertEquals("guarded", after.selectedLink?.name) + } + + @Test + fun skipPlaysTheBestFoundSoFarAndStopsFindingLinks() { + api.gate = CompletableDeferred() + api.slowAfter = 2 + // A long wait before picking, so only the skip plays a link + val vm = links("sd" to 720, "hd" to 1080, "late" to 2160, autoPickMs = 60_000) + await(vm.state) { it.links.size == 2 } + assertNull(vm.state.value.selected) + + vm.onAction(LinksAction.Skip) + val skipped = vm.state.value + assertEquals(false, skipped.loading) + assertEquals("hd", skipped.selectedLink?.name) + // The extractor still running is left out + api.gate!!.complete(Unit) + Thread.sleep(200) + assertEquals(listOf("hd", "sd"), vm.state.value.links.map { it.name }) + } + + @Test + fun noLinksMeansExhausted() { + val vm = links() + val state = await(vm.state) { !it.loading } + assertNull(state.selected) + assertTrue(state.exhausted) + } + + @Test + fun episodeIdsAreTheAndroidAppsIds() { + val vm = DetailViewModel(api, "https://example.invalid/series") + val state = await(vm.state) { it.status == DetailStatus.Done } + // The url without the provider's address and slashes, hashed, like ResultViewModel2.getId + val titleId = "series".hashCode() + assertEquals(titleId, state.titleId) + // Series episodes sort by season then number: extra, S1 E1, S1 E2, S2 E1 + val pilot = state.seasons[0].episodes[0] + assertEquals(titleId + 100_000 + 1 + 1, state.episodeIds[pilot]) + + val anime = await(DetailViewModel(api, "https://example.invalid/anime").state) { it.status == DetailStatus.Done } + val dubbed = anime.episodeIds.entries.single { it.key.data == "dub1" }.value + assertEquals("anime".hashCode() + 1 + 1_000_000, dubbed) + } + + @Test + fun libraryAndResumeFollowWhatWasWatched() { + val store = InMemoryWatchStore() + val vm = DetailViewModel(api, "https://example.invalid/series", store) + val state = await(vm.state) { it.status == DetailStatus.Done } + val (pilot, second) = state.seasons[0].episodes + + vm.onAction(DetailAction.SetBookmark(WatchType.WATCHING)) + assertEquals(WatchType.WATCHING, vm.state.value.bookmark) + assertEquals("A series", store.bookmark(state.titleId!!)!!.header.name) + + // Halfway through the pilot: Resume picks it up there + val tracking = vm.playRequest(pilot)!!.tracking!! + assertEquals(state.episodeIds[second], tracking.next!!.id) + store.saveProgress(tracking, positionMs = 600_000, durationMs = 1_200_000) + await(vm.state) { it.resume?.episode == pilot } + assertEquals(600_000, vm.state.value.resume!!.positionMs) + assertEquals(0.5f, vm.state.value.progressOf(pilot)!!.fraction) + assertEquals(600_000, vm.playRequest(pilot)!!.tracking!!.startPositionMs) + // Continue watching plays the same, from the source last watched + store.setLastSource(state.titleId!!, SavedSource("hd", 1080)) + assertEquals(vm.playRequest(pilot), vm.resumeRequest()) + assertEquals(SavedSource("hd", 1080), vm.resumeRequest()!!.preferredSource) + + // Nearly finished: Continue watching moves on to the next episode, which starts at the beginning + store.saveProgress(tracking, positionMs = 1_150_000, durationMs = 1_200_000) + await(vm.state) { it.resume?.episode == second } + assertTrue(vm.state.value.progressOf(pilot)!!.watched) + assertEquals(0, vm.playRequest(pilot)!!.tracking!!.startPositionMs, "A finished episode starts again from the beginning") + + // Finishing the last episode takes the title out of Continue watching + val extra = vm.playRequest(state.seasons[2].episodes.single())!!.tracking!! + assertNull(extra.next) + store.saveProgress(extra, positionMs = 1_190_000, durationMs = 1_200_000) + assertNull(store.resume(state.titleId!!)) + + vm.onAction(DetailAction.SetBookmark(WatchType.NONE)) + assertNull(store.bookmark(state.titleId!!)) + } + + @Test + fun shortVideosAreNotRemembered() { + val store = InMemoryWatchStore() + val vm = DetailViewModel(api, "https://example.invalid/movie", store) + await(vm.state) { it.status == DetailStatus.Done } + store.saveProgress(vm.playRequest()!!.tracking!!, positionMs = 10_000, durationMs = 20_000) + assertTrue(store.resumeEntries().isEmpty()) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/QuietPrintStreamTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/QuietPrintStreamTest.kt new file mode 100644 index 00000000000..346a2c49499 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/QuietPrintStreamTest.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream4 + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.SocketTimeoutException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QuietPrintStreamTest { + @Test + fun cancelledRequestsLeaveNothingAndEverythingElsePasses() { + val bytes = ByteArrayOutputStream() + val out = QuietPrintStream(bytes) + out.println("INFO before") + // What NiceHttp prints for a request an extension cancelled + out.println("Exception in NiceHttp: java.io.IOException Canceled") + IOException("Canceled").apply { addSuppressed(SocketTimeoutException("Connect timed out")) }.printStackTrace(out) + // A real error is kept, with its trace + IOException("Connection reset").printStackTrace(out) + out.println("INFO after") + out.flush() + + val lines = bytes.toString(Charsets.UTF_8).lines().filter { it.isNotEmpty() } + assertEquals("INFO before", lines.first()) + assertEquals("java.io.IOException: Connection reset", lines[1]) + assertTrue(lines[2].startsWith("\tat ")) + assertEquals("INFO after", lines.last()) + assertTrue(lines.none { "Canceled" in it || "Connect timed out" in it }, lines.joinToString("\n")) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt new file mode 100644 index 00000000000..ebbb110ef71 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt @@ -0,0 +1,159 @@ +package com.lagradost.cloudstream4.browser + +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.network.CloudflareKiller +import com.lagradost.cloudstream3.network.DesktopWebView +import com.lagradost.cloudstream3.network.WebViewResolver +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.net.InetSocketAddress +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Drives the installed Edge or Chrome, so it only runs when asked for with + * -Dcloudstream.browserTests=true (gradle: -PbrowserTests), on a machine with one of them. + */ +class SystemBrowserTest { + private val enabled = System.getProperty("cloudstream.browserTests") == "true" && SystemBrowser.find() != null + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/player") { exchange -> + val page = """ + <html><head><title>Player + + + """.trimIndent().toByteArray() + exchange.responseHeaders.add("Content-Type", "text/html") + exchange.sendResponseHeaders(200, page.size.toLong()) + exchange.responseBody.use { it.write(page) } + } + // Like Cloudflare's check: passed in a window once the test lets it, which sets a cookie + createContext("/guarded") { exchange -> + val passed = exchange.requestHeaders["Cookie"].orEmpty().any { "passed=1" in it } + if (!passed && guardOpen) exchange.responseHeaders.add("Set-Cookie", "passed=1; Path=/; Max-Age=600") + val page = when { + passed -> """Player""" + guardOpen -> "PassedYou are human" + else -> "Just a moment...Checking your browser" + }.toByteArray() + exchange.responseHeaders.add("Content-Type", "text/html") + exchange.sendResponseHeaders(200, page.size.toLong()) + exchange.responseBody.use { it.write(page) } + } + // Like an ad: opens more tabs, which Windows would list in Alt+Tab + createContext("/ads") { exchange -> + val page = """ + Ads + + + + """.trimIndent().toByteArray() + exchange.responseHeaders.add("Content-Type", "text/html") + exchange.sendResponseHeaders(200, page.size.toLong()) + exchange.responseBody.use { it.write(page) } + } + createContext("/stream") { exchange -> + exchange.sendResponseHeaders(200, 0) + exchange.responseBody.close() + } + start() + } + private val base = "http://127.0.0.1:${server.address.port}" + @Volatile private var guardOpen = false + private val browser = if (enabled) SystemBrowser(SystemBrowser.find()!!, Files.createTempDirectory("cs-browser").toFile()) else null + + @AfterTest + fun stop() { + server.stop(0) + browser?.stop() + DesktopWebView.engine = null + } + + @Test + fun findsTheRequestAPageMakesAndItsCookies() = runBlocking { + if (browser == null) return@runBlocking + DesktopWebView.engine = browser + val (found, extra) = WebViewResolver(Regex("""master\.m3u8"""), additionalUrls = listOf(Regex("/player")), timeout = 30_000) + .resolveUsingWebView("$base/player", referer = "https://referer.invalid/") + assertNotNull(found, "the page's request for the stream") + assertEquals("$base/stream/master.m3u8?token=abc", found.url.toString()) + assertEquals("abc", found.header("X-Token")) + assertEquals(listOf("$base/player"), extra.map { it.url.toString() }) + assertTrue(browser.cookies("$base/player").orEmpty().contains("seen=yes")) + assertTrue(WebViewResolver.webViewUserAgent.orEmpty().contains("Mozilla")) + // Sites refuse a headless browser, which says so in its user agent + assertFalse(WebViewResolver.webViewUserAgent.orEmpty().contains("Headless")) + assertFalse(found.header("User-Agent").orEmpty().contains("Headless")) + } + + @Test + fun keepsNoTabOnceAPageIsLoaded() = runBlocking { + if (browser == null) return@runBlocking + DesktopWebView.engine = browser + val (found, _) = WebViewResolver(Regex("""ads\.m3u8"""), timeout = 30_000).resolveUsingWebView("$base/ads") + assertNotNull(found, "the page's request for the stream") + // Neither the tab the browser starts with nor the page's popups are left behind + delay(1_000) + assertEquals(emptyList(), browser.tabs()) + } + + /** A test page behind Cloudflare's check (it answers 403 to plain clients), so only with -PcloudflareTest as well */ + @Test + fun passesCloudflaresCheck() = runBlocking { + if (browser == null || System.getProperty("cloudstream.cloudflareTest") != "true") return@runBlocking + DesktopWebView.engine = browser + val url = System.getProperty("cloudstream.cloudflareUrl").orEmpty().ifBlank { "https://www.scrapingcourse.com/cloudflare-challenge" } + val direct = app.get(url, timeout = 30) + println("Without the browser: ${direct.code} ${direct.headers["Server"]}") + val response = app.get(url, interceptor = CloudflareKiller(), timeout = 90) + val heading = Regex("(.*?)|]*>(.*?)").findAll(response.text).joinToString(" / ") { it.value } + println("With the browser: ${response.code}: $heading") + assertEquals(200, response.code) + } + + /** Opens a browser window for a moment, for the check */ + @Test + fun aSiteThatWantsAPersonGivesUpAtOnceAndIsPassedInAWindowWhenAsked() = runBlocking { + if (browser == null) return@runBlocking + DesktopWebView.engine = browser + val asked = async(start = CoroutineStart.UNDISPATCHED) { browser.asked.first() } + val start = System.nanoTime() + val (blocked, _) = WebViewResolver(Regex("""guarded\.m3u8"""), timeout = 60_000).resolveUsingWebView("$base/guarded") + assertNull(blocked) + // It gave up once the check showed it wants a person, not after the whole minute + assertTrue(System.nanoTime() - start < 30_000_000_000, "gave up after ${(System.nanoTime() - start) / 1_000_000} ms") + assertEquals("127.0.0.1", asked.await()) + + guardOpen = true + assertTrue(browser.pass("127.0.0.1")) + // The cookie of the passed check came back to the browser without a window + val (found, _) = WebViewResolver(Regex("""guarded\.m3u8"""), timeout = 30_000).resolveUsingWebView("$base/guarded") + assertNotNull(found, "the page's request after the check") + } + + @Test + fun withoutABrowserNothingIsFound() = runBlocking { + DesktopWebView.engine = null + val (found, extra) = WebViewResolver(Regex("x")).resolveUsingWebView("$base/player") + assertNull(found) + assertTrue(extra.isEmpty()) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt new file mode 100644 index 00000000000..80e2b890216 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt @@ -0,0 +1,269 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.File +import java.net.InetSocketAddress +import java.nio.file.Files +import java.util.Collections +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DownloadsTest { + private val dir = Files.createTempDirectory("cs-downloads").toFile() + + init { + DesktopAndroid.init(dir.toPath().resolve("home")) + } + + private val video = ByteArray(300_000) { (it % 251).toByte() } + private val segments = List(3) { n -> ByteArray(50_000) { (n * 7 + it % 13).toByte() } } + private val ranges = Collections.synchronizedList(ArrayList()) + private val segmentHits = Collections.synchronizedList(ArrayList()) + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/video.mp4") { exchange -> + val range = exchange.requestHeaders.getFirst("Range") + ranges += range + val from = range?.removePrefix("bytes=")?.substringBefore('-')?.toInt() ?: 0 + exchange.responseHeaders.add("Content-Type", "video/mp4") + if (from > 0) exchange.responseHeaders.add("Content-Range", "bytes $from-${video.size - 1}/${video.size}") + exchange.send(if (from > 0) 206 else 200, video.copyOfRange(from, video.size)) + } + createContext("/page.mp4") { it.responseHeaders.add("Content-Type", "text/html"); it.send(200, "".toByteArray()) } + createContext("/stream.m3u8") { exchange -> + val playlist = buildString { + appendLine("#EXTM3U") + appendLine("#EXT-X-TARGETDURATION:10") + segments.indices.forEach { appendLine("#EXTINF:10,"); appendLine("seg$it.ts") } + appendLine("#EXT-X-ENDLIST") + } + exchange.send(200, playlist.toByteArray()) + } + segments.indices.forEach { n -> + createContext("/seg$n.ts") { segmentHits += n; it.send(200, segments[n]) } + } + createContext("/sub.vtt") { it.send(200, "WEBVTT\n\n00:00.000 --> 00:01.000\nHi\n".toByteArray()) } + start() + } + private val base = "http://127.0.0.1:${server.address.port}" + + private fun HttpExchange.send(code: Int, body: ByteArray) { + sendResponseHeaders(code, body.size.toLong()) + responseBody.use { it.write(body) } + } + + @AfterTest + fun cleanup() { + server.stop(0) + dir.deleteRecursively() + } + + private fun link(path: String, type: ExtractorLinkType = ExtractorLinkType.VIDEO, name: String = "Test", quality: Int = 1080) = runBlocking { + newExtractorLink("Test", name, "$base$path", type) { this.quality = quality } + } + + @Test + fun namesFilesAndFoldersAsAndroidDoes() { + assertEquals("TVSeries/A show 2", DownloadNames.folder(TvType.TvSeries, "A show: 2?")) + assertEquals("Movies", DownloadNames.folder(TvType.Movie, "A film")) + assertEquals("Season 1 Episode 2 - Pilot", DownloadNames.fileName("A show", 2, 1, "Pilot")) + assertEquals("Episode 2", DownloadNames.fileName("A show", 2, null, " ")) + assertEquals("A film", DownloadNames.fileName("A film.", null, null, null)) + } + + @Test + fun aVideoCarriesOnWhereItStopped() = runBlocking { + val file = File(dir, "out/video.mp4") + file.parentFile.mkdirs() + file.writeBytes(video.copyOf(100_000)) + Downloader.download(link("/video.mp4"), file, Downloader.Resume(0, 0), 3) { _, _, _ -> } + assertEquals("bytes=100000-", ranges.single()) + assertContentEquals(video, file.readBytes()) + } + + @Test + fun aWebPageIsNotSavedAsAVideo() { + val file = File(dir, "page.mp4") + val error = runCatching { runBlocking { Downloader.download(link("/page.mp4"), file, null, 3) { _, _, _ -> } } }.exceptionOrNull() + assertNotNull(error) + } + + @Test + fun anHlsStreamIsJoinedAndResumesAfterTheLastSavedSegment() = runBlocking { + val file = File(dir, "hls.mp4") + var last: Triple? = null + Downloader.download(link("/stream.m3u8", ExtractorLinkType.M3U8), file, null, 2) { bytes, total, done -> last = Triple(bytes, total, done) } + val joined = segments.reduce { a, b -> a + b } + assertContentEquals(joined, file.readBytes()) + assertEquals(Triple(joined.size.toLong(), joined.size.toLong(), 3), last) + + // Stopped after segment 1 was saved, with part of segment 2 written after that + file.writeBytes(segments[0] + segments[1].copyOf(10_000)) + segmentHits.clear() + Downloader.download(link("/stream.m3u8", ExtractorLinkType.M3U8), file, Downloader.Resume(1, segments[0].size.toLong()), 2) { _, _, _ -> } + assertContentEquals(joined, file.readBytes()) + assertEquals(listOf(1, 2), segmentHits.sorted()) + } + + @Test + fun downloadsThePickedSourceAndKeepsTheAndroidKeys() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val root = File(dir, "Downloads") + // Only for resuming: the picked source downloads without finding links again + val finder = LinkFinder { _, _, _ -> error("not needed") } + val downloads = Downloads(DownloadStore(), scope, finder, root = { root }, subtitleLanguage = { "en" }) + val title = DownloadedTitle(7, "Test", "$base/show", "A show", TvType.TvSeries, null) + val episode = DownloadedEpisode(70, 7, "Pilot", 1, 1, null, null) + downloads.start( + DownloadRequest(title, episode, DownloadSource("Test", "data"), link("/video.mp4"), listOf(SubtitleFile("English", "$base/sub.vtt"))), + ) + val done = withTimeout(20_000) { downloads.states.first { it[70]?.status == DownloadStatus.Done || it[70]?.status == DownloadStatus.Failed } } + assertEquals(DownloadStatus.Done, done[70]?.status, done[70]?.message) + + val file = downloads.file(70)!! + assertEquals(File(root, "TVSeries/A show/Season 1 Episode 1 - Pilot.mp4"), file) + assertContentEquals(video, file.readBytes()) + assertEquals(listOf("English"), downloads.subtitles(70).map { it.first }) + assertEquals(listOf(title), downloads.titles()) + assertEquals(listOf(70), downloads.episodes(7).map { it.id }) + + // A new start reads what is saved: the finished file is still done + val again = Downloads(DownloadStore(), scope, finder, root = { root }) + assertEquals(DownloadStatus.Done, again.state(70)?.status) + assertTrue(again.canResume(7, 70)) + + val subtitle = File(root, "TVSeries/A show/Season 1 Episode 1 - Pilot English.vtt") + assertTrue(subtitle.exists()) + again.delete(7, 70) + // The files go in the background, the video first + withTimeout(5_000) { while (file.exists() || subtitle.exists()) kotlinx.coroutines.delay(50) } + assertNull(again.state(70)) + assertTrue(again.titles().isEmpty()) + // The series' folders it left empty go too, the downloads folder stays + withTimeout(5_000) { while (File(root, "TVSeries").exists()) kotlinx.coroutines.delay(50) } + assertTrue(root.isDirectory) + scope.cancel() + } + + @Test + fun aHalfDownloadHeldOpenIsStillDeletedOnceItIsLetGo() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val root = File(dir, "Downloads") + val store = DownloadStore() + val title = DownloadedTitle(11, "Test", "$base/film3", "Held film", TvType.Movie, null) + val movie = DownloadedEpisode(11, 11, null, null, null, null, null) + store.saveTitle(title) + store.saveEpisode(movie, DownloadSource("Test", "data")) + val partial = DownloadFile(video.size.toLong(), root, "Movies", "Held film.mp4") + partial.folder.mkdirs() + partial.file.writeBytes(video.copyOf(100_000)) + File(root, "Movies/Other film.mp4").writeBytes(ByteArray(10)) + store.saveFile(11, partial) + val downloads = Downloads(store, scope, LinkFinder { _, _, _ -> }, root = { root }) + assertEquals(DownloadStatus.Paused, downloads.state(11)?.status) + + // Something else has it open, as the player or a virus scan can: Windows refuses to delete it + val held = java.io.FileInputStream(partial.file) + downloads.delete(11, 11) + assertNull(downloads.state(11)) + kotlinx.coroutines.delay(800) + held.close() + withTimeout(8_000) { while (partial.file.exists()) kotlinx.coroutines.delay(50) } + // Another film is still in the folder, so it stays + assertTrue(File(root, "Movies").isDirectory) + scope.cancel() + } + + @Test + fun theSourcesToPickFromAreDownloadableOnesBestFirst() { + val picked = Downloads.forPicking( + listOf( + link("/a.mp4", quality = 720), + link("/t", ExtractorLinkType.MAGNET, quality = 2160), + link("/b.m3u8", ExtractorLinkType.M3U8, quality = 1080), + link("/a.mp4", quality = 720), + ), + ) + assertEquals(listOf("$base/b.m3u8", "$base/a.mp4"), picked.map { it.url }) + } + + @Test + fun aPickedSourceThatFailsAsksForAnotherInsteadOfTakingOne() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val finder = LinkFinder { _, onLink, _ -> onLink(link("/video.mp4", quality = 2160)) } + val downloads = Downloads(DownloadStore(), scope, finder, root = { File(dir, "Downloads") }) + val title = DownloadedTitle(8, "Test", "$base/film", "A film", TvType.Movie, null) + val movie = DownloadedEpisode(8, 8, null, null, null, null, null) + downloads.start(DownloadRequest(title, movie, DownloadSource("Test", "data"), link("/page.mp4"))) + val failed = withTimeout(10_000) { downloads.states.first { it[8]?.status == DownloadStatus.Failed } }[8]!! + assertTrue(failed.pickAgain) + assertNull(downloads.file(8)) + scope.cancel() + } + + @Test + fun resumingCarriesOnWithThePickedSourceFoundByItsName() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val root = File(dir, "Downloads") + var mirror = true + // The source's url changed since it was picked, and a better quality is found too + val finder = LinkFinder { _, onLink, _ -> + onLink(link("/stream.m3u8", ExtractorLinkType.M3U8, name = "Big", quality = 2160)) + if (mirror) onLink(link("/video.mp4?token=new", name = "Mirror A", quality = 720)) + } + val store = DownloadStore() + val title = DownloadedTitle(9, "Test", "$base/film2", "Another film", TvType.Movie, null) + val movie = DownloadedEpisode(9, 9, null, null, null, null, null) + val request = DownloadRequest(title, movie, DownloadSource("Test", "data")) + store.saveTitle(title) + store.saveEpisode(movie, request.source) + val partial = DownloadFile(video.size.toLong(), root, "Movies", "Another film.mp4", linkHash = "$base/video.mp4?token=old".hashCode(), linkName = "Mirror A") + partial.folder.mkdirs() + partial.file.writeBytes(video.copyOf(100_000)) + store.saveFile(9, partial) + + val downloads = Downloads(store, scope, finder, root = { root }) + assertEquals(DownloadStatus.Paused, downloads.state(9)?.status) + downloads.start(request) + val done = withTimeout(20_000) { downloads.states.first { it[9]?.status == DownloadStatus.Done || it[9]?.status == DownloadStatus.Failed } }[9]!! + assertEquals(DownloadStatus.Done, done.status, done.message) + // Another url is another file, so it started again, and not with the bigger quality + assertContentEquals(video, partial.file.readBytes()) + assertTrue(segmentHits.isEmpty()) + + // Gone from the extension: the user picks another, nothing else is taken + downloads.delete(9, 9) + withTimeout(5_000) { while (partial.file.exists()) kotlinx.coroutines.delay(50) } + store.saveTitle(title) + store.saveEpisode(movie, request.source) + // Deleting took the folder it left empty with it + partial.folder.mkdirs() + partial.file.writeBytes(video.copyOf(100_000)) + store.saveFile(9, partial) + mirror = false + val again = Downloads(store, scope, finder, root = { root }) + again.start(request) + val gone = withTimeout(10_000) { again.states.first { it[9]?.status == DownloadStatus.Failed } }[9]!! + assertTrue(gone.pickAgain) + assertTrue(segmentHits.isEmpty()) + scope.cancel() + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt new file mode 100644 index 00000000000..b096e96a5b7 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt @@ -0,0 +1,59 @@ +package com.lagradost.cloudstream4.library + +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DataStoreWatchStoreTest { + private val dir = Files.createTempDirectory("cs-watch") + + init { + DesktopAndroid.init(dir) + } + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + private val header = TitleHeader(42, "Catalog", "https://example.invalid/series", "A series", TvType.TvSeries, "https://example.invalid/p.jpg", year = 2024) + + @Test + fun savesInTheAndroidAppsKeysAndReadsBack() { + val store = DataStoreWatchStore(clock = { 1_000 }) + store.setBookmark(header, WatchType.PLANTOWATCH) + store.setPosition(4243, 60_000, 1_200_000) + store.setResume(ResumeEntry(header, 4243, 1, 1, 2_000)) + + // A new store reads what the last one wrote, as after a restart + val again = DataStoreWatchStore() + assertEquals(WatchType.PLANTOWATCH, again.bookmark(42)!!.status) + assertEquals(header, again.bookmark(42)!!.header) + assertEquals(PlaybackPosition(60_000, 1_200_000), again.position(4243)) + // Continue watching keeps only what the Android app's header cache has + assertEquals(listOf(ResumeEntry(header.copy(year = null), 4243, 1, 1, 2_000)), again.resumeEntries()) + + // Writes go to the file in the background, wait for them + val file = dir.resolve("shared_prefs/rebuild_preference.json") + val keys = listOf("0/result_watch_state/42", "0/result_watch_state_data/42", "0/video_pos_dur/4243", "0/result_resume_watching_2/42", "download_header_cache/42") + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline && !(Files.exists(file) && keys.all { it in file.readText() })) Thread.sleep(50) + keys.forEach { assertTrue(it in file.readText(), "$it is saved under the Android app's key") } + + // The source last watched, desktop's own key + assertNull(again.lastSource(42)) + again.setLastSource(42, SavedSource("Vidplay", 1080)) + assertEquals(SavedSource("Vidplay", 1080), DataStoreWatchStore().lastSource(42)) + + again.setBookmark(header, WatchType.NONE) + again.removeResume(42) + assertNull(again.bookmark(42)) + assertTrue(again.resumeEntries().isEmpty()) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/network/DesktopHttpTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/network/DesktopHttpTest.kt new file mode 100644 index 00000000000..9f521f83b78 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/network/DesktopHttpTest.kt @@ -0,0 +1,62 @@ +package com.lagradost.cloudstream4.network + +import com.lagradost.cloudstream3.app +import com.sun.net.httpserver.HttpServer +import okhttp3.Request +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DesktopHttpTest { + private val hits = AtomicInteger() + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/fresh") { exchange -> + hits.incrementAndGet() + val body = "repo list".toByteArray() + exchange.responseHeaders.add("Cache-Control", "max-age=300") + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + start() + } + private val base = "http://127.0.0.1:${server.address.port}" + + @AfterTest + fun stop() = server.stop(0) + + private fun get(path: String) = DesktopHttp.client.newCall(Request.Builder().url(base + path).build()).execute().use { it.body.string() } + + @Test + fun answersRepeatedRequestsFromItsCacheWhileTheServerSaysTheyAreFresh() { + assertEquals("repo list", get("/fresh")) + assertEquals("repo list", get("/fresh")) + assertEquals(1, hits.get(), "the second request came from the cache") + } + + @Test + fun theExtensionsUseIt() { + DesktopHttp.install(0) + assertSame(DesktopHttp.client, app.baseClient) + } + + @Test + fun everyDnsChoiceBuildsAClient() { + for (dns in DesktopHttp.dnsProviders.keys) { + DesktopHttp.install(dns) + assertSame(DesktopHttp.client, app.baseClient) + assertTrue(DesktopHttp.client.cache != null) + } + DesktopHttp.install(0) + } + + @Test + fun theUncachedCallsSkipTheCache() { + DesktopHttp.uncached.newCall(Request.Builder().url("$base/fresh").build()).execute().close() + DesktopHttp.uncached.newCall(Request.Builder().url("$base/fresh").build()).execute().close() + assertEquals(2, hits.get()) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/player/SubtitleSettingsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/player/SubtitleSettingsTest.kt new file mode 100644 index 00000000000..f356acd97db --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/player/SubtitleSettingsTest.kt @@ -0,0 +1,126 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SubtitleSettingsTest { + private val dir = Files.createTempDirectory("cs-subtitles") + + init { + DesktopAndroid.init(dir) + } + + /** The saved preferences once they hold [text]: they are written to disk in the background */ + private fun savedWith(text: String): String { + val file = dir.resolve("shared_prefs/rebuild_preference.json") + fun read() = runCatching { file.readText() }.getOrDefault("") + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline && text !in read()) Thread.sleep(50) + return read() + } + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + @Test + fun savesInTheAndroidAppsKeysAndReadsBack() { + val settings = DataStoreSubtitleSettings() + assertEquals(SubtitleStyle(), settings.style.value) + assertEquals("en", settings.autoSelect.value) + + val style = SubtitleStyle(foregroundColor = 0xFFFFFF00.toInt(), font = "Verdana", fixedTextSize = 30f, bold = true) + settings.setStyle(style) + settings.setAutoSelect("hi") + + // A new store reads what the last one wrote, as after a restart + val again = DataStoreSubtitleSettings() + assertEquals(style, again.style.value) + assertEquals("hi", again.autoSelect.value) + + val file = savedWith("subs_auto_select") + assertTrue("subtitle_settings" in file && "fixedTextSize" in file && "foregroundColor" in file, file) + } + + @Test + fun readsWhatTheAndroidAppSaved() { + // As the Android app writes it: its font enum by name, sizes as floats + DesktopAndroid.application.setKey( + "subtitle_settings", + mapOf( + "foregroundColor" to -1, "backgroundColor" to 0, "windowColor" to 0, "edgeType" to 2, + "edgeColor" to -16777216, "font" to "Netflix", "typefaceFilePath" to null, "elevation" to 30, + "fixedTextSize" to 28.0, "edgeSize" to null, "removeCaptions" to true, "removeBloat" to true, + "upperCase" to false, "bold" to false, "italic" to true, "backgroundRadius" to null, "alignment" to null, + ), + ) + DesktopAndroid.application.setKey("subs_auto_select", "None") + + val settings = DataStoreSubtitleSettings() + val style = settings.style.value + assertEquals("Netflix", style.font) + assertEquals(SubtitleStyle.EDGE_DROP_SHADOW, style.edgeType) + assertEquals(28f, style.fixedTextSize) + assertEquals(30, style.elevation) + assertTrue(style.italic && style.removeCaptions) + assertNull(settings.autoSelect.value) + + // Writing keeps the fields the desktop doesn't use + settings.setStyle(style.copy(bold = true)) + assertTrue(DataStoreSubtitleSettings().style.value.removeCaptions) + } + + @Test + fun noneIsSavedTheWayAndroidSavesIt() { + val settings = DataStoreSubtitleSettings() + settings.setAutoSelect(null) + assertTrue("None" in savedWith("None"), "saved as the Android app saves it") + assertNull(DataStoreSubtitleSettings().autoSelect.value) + } + + @Test + fun mapsTheStyleToMpvOptions() { + val defaults = SubtitleStyle().mpvOptions().toMap() + assertEquals("38", defaults["sub-font-size"]) + assertEquals("#FFFFFFFF", defaults["sub-color"]) + assertEquals("outline-and-shadow", defaults["sub-border-style"]) + assertEquals("2.0", defaults["sub-outline-size"]) + assertEquals("0.0", defaults["sub-shadow-offset"]) + assertEquals("sans-serif", defaults["sub-font"]) + // Also on subtitles with styles of their own, as on Android + assertEquals("force", defaults["sub-ass-override"]) + + val boxed = SubtitleStyle( + backgroundColor = 0x80000000.toInt(), + edgeType = SubtitleStyle.EDGE_DROP_SHADOW, + font = "TimesNewRoman", + fixedTextSize = 40f, + italic = true, + ).mpvOptions().toMap() + assertEquals("background-box", boxed["sub-border-style"]) + assertEquals("#80000000", boxed["sub-back-color"]) + assertEquals("0.0", boxed["sub-outline-size"]) + assertEquals("2.0", boxed["sub-shadow-offset"]) + assertEquals("Times New Roman", boxed["sub-font"]) + assertEquals("60", boxed["sub-font-size"]) + assertEquals("yes", boxed["sub-italic"]) + } + + @Test + fun findsTheLanguageOfNamesAndCodes() { + assertEquals("en", SubtitleLanguages.tagOf("English")) + assertEquals("en", SubtitleLanguages.tagOf("eng")) + assertEquals("en", SubtitleLanguages.tagOf("English SDH")) + assertEquals("hi", SubtitleLanguages.tagOf("hin")) + assertNull(SubtitleLanguages.tagOf(null)) + assertEquals(listOf("en", "eng"), SubtitleLanguages.codesOf("en")) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt new file mode 100644 index 00000000000..bcd6eac660b --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt @@ -0,0 +1,233 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.plugins.PluginData +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.lagradost.cloudstream4.android.DexConverter +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.lang.management.ManagementFactory +import java.lang.management.MemoryType +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.Test + +/** + * Converts, loads and searches with every Android extension (.cs3) in a folder, and reports how + * many work on desktop. It needs the internet and the extensions, so it only runs when + * CLOUDSTREAM_ANDROID_EXTENSIONS is set to the folder. The report goes to build/android-extensions. + */ +class AndroidExtensionsTest { + private enum class Outcome { + /** A provider returned search results */ + WORKS, + /** Searches ran but found nothing, which is often a site that blocks or changed */ + NO_RESULTS, + /** The site or network failed, not desktop */ + SITE_ERROR, + /** Every search ran past the hard limit */ + TIMED_OUT, + /** A class or member desktop lacks */ + MISSING_ON_DESKTOP, + /** Loaded, but registered no provider to search with, such as extractor-only plugins */ + NO_PROVIDER, + LOAD_FAILED, + CONVERSION_FAILED, + } + + private class Report(val name: String) { + var outcome = Outcome.LOAD_FAILED + var detail = "" + var convertMillis = 0L + /** The most heap in use while it converted, with what was in use before */ + var convertPeakMb = 0L + var failedMethods = 0 + var providers = listOf() + var results = 0 + var stubbed = setOf() + } + + /** + * Runs [block] on its own thread and gives up on it after [limitMs]. Some extensions block in + * code a coroutine timeout cannot stop (a socket read without a timeout, a busy loop), which + * once held a full run for over 90 minutes; an abandoned thread is a daemon and is left behind. + */ + private fun withHardLimit(limitMs: Long, what: String, block: suspend () -> T): T { + val future = hardLimitThreads.submit { runBlocking { block() } } + try { + return future.get(limitMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + future.cancel(true) + throw HardLimitException("$what took over ${limitMs / 1000} s") + } catch (e: ExecutionException) { + throw e.cause ?: e + } + } + + /** Collects first, so the peak starts from what is really in use */ + private fun resetHeapPeak() { + System.gc() + heapPools().forEach { it.resetPeakUsage() } + } + + /** Pools peak at different moments, so their sum is at most this much higher than the real peak */ + private fun heapPeakMb(): Long = heapPools().sumOf { it.peakUsage.used } / (1024 * 1024) + + private fun heapPools() = ManagementFactory.getMemoryPoolMXBeans().filter { it.type == MemoryType.HEAP } + + private class HardLimitException(message: String) : Exception(message) + + private val hardLimitThreads = Executors.newCachedThreadPool { task -> + Thread(task, "extension-test").apply { isDaemon = true } + } + + private fun Throwable.isMissingOnDesktop(): Boolean = + generateSequence(this) { it.cause }.any { it is LinkageError || it is AbstractMethodError || it is ClassCastException } + + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + @Test + fun androidExtensionsWorkOnDesktop() = runBlocking { + val folder = System.getenv("CLOUDSTREAM_ANDROID_EXTENSIONS") ?: return@runBlocking println("Skipped, set CLOUDSTREAM_ANDROID_EXTENSIONS") + // Common words in a few languages and a popular title, since sites search in their own language + val queries = System.getenv("CLOUDSTREAM_SEARCH_QUERY")?.split(',') ?: listOf("the", "a", "naruto", "love") + // The app has Swing's main dispatcher, extensions use Dispatchers.Main when they load + Dispatchers.setMain(newSingleThreadContext("Test main")) + val work = Files.createTempDirectory("cs-android-extensions") + DesktopAndroid.init(work.resolve("android")) + val out = Path.of("build", "android-extensions").createDirectories() + val files = Path.of(folder).listDirectoryEntries("*.cs3").sortedBy { it.name } + println("Testing ${files.size} extensions, searching for $queries") + // Where each came from, as {"": ""}. Some extensions only + // register when the app says they were installed from their own repository + val sources = Path.of(folder).resolve("sources.json").takeIf { it.exists() }?.let { file -> + Json.parseToJsonElement(file.readText()).jsonObject.mapValues { it.value.jsonPrimitive.content } + }.orEmpty() + val installed = mutableListOf() + PluginManager.installed = { installed.toList() } + + // Convert and load one at a time, plugins register into one global list + val reports = files.map { cs3 -> + val report = Report(cs3.name.removeSuffix(".cs3")) + val jar = work.resolve(cs3.name.removeSuffix(".cs3") + ".jar") + installed += PluginData(report.name, sources[report.name], true, jar.toString(), 1) + try { + val start = System.nanoTime() + resetHeapPeak() + report.failedMethods = DexConverter.convert(cs3, jar).failedMethods + report.convertMillis = (System.nanoTime() - start) / 1_000_000 + report.convertPeakMb = heapPeakMb() + } catch (t: Throwable) { + report.outcome = Outcome.CONVERSION_FAILED + report.detail = t.toString() + return@map report to null + } + val loaded = try { + withHardLimit(LOAD_LIMIT_MS, "Loading") { PluginLoader.load(jar) } + } catch (t: Throwable) { + report.outcome = if (t.isMissingOnDesktop()) Outcome.MISSING_ON_DESKTOP else Outcome.LOAD_FAILED + report.detail = "load: $t" + return@map report to null + } + report.providers = loaded.providers.map { it.name } + report to loaded + } + + val searches = Semaphore(12) + reports.map { (report, loaded) -> + async(Dispatchers.IO) { + if (loaded == null) return@async + val providers = loaded.providers + if (providers.isEmpty()) { + report.outcome = Outcome.NO_PROVIDER + return@async + } + val errors = mutableListOf() + for (provider in providers) { + for (query in queries) { + val items = searches.withPermit { + try { + withHardLimit(SEARCH_LIMIT_MS, "Searching") { provider.search(query, 1)?.items.orEmpty() } + } catch (t: Throwable) { + errors += t + emptyList() + } + } + report.results += items.size + if (items.isNotEmpty()) break + } + } + report.outcome = when { + report.results > 0 -> Outcome.WORKS + errors.any { it.isMissingOnDesktop() } -> Outcome.MISSING_ON_DESKTOP + errors.isNotEmpty() && errors.all { it is HardLimitException } -> Outcome.TIMED_OUT + errors.isNotEmpty() -> Outcome.SITE_ERROR + else -> Outcome.NO_RESULTS + } + report.detail = (errors.firstOrNull { it.isMissingOnDesktop() } ?: errors.firstOrNull())?.let { + it.toString() + " at " + it.stackTrace.take(3).joinToString(" < ") + } ?: "" + } + }.awaitAll() + for ((report, loaded) in reports) { + report.stubbed = loaded?.stubbedClasses.orEmpty() + runCatching { loaded?.unload() } + } + + val all = reports.map { it.first } + val json = JsonArray(all.map { + JsonObject( + mapOf( + "name" to JsonPrimitive(it.name), + "outcome" to JsonPrimitive(it.outcome.name), + "detail" to JsonPrimitive(it.detail), + "convertMillis" to JsonPrimitive(it.convertMillis), + "convertPeakMb" to JsonPrimitive(it.convertPeakMb), + "failedMethods" to JsonPrimitive(it.failedMethods), + "providers" to JsonArray(it.providers.map(::JsonPrimitive)), + "results" to JsonPrimitive(it.results), + "stubbed" to JsonArray(it.stubbed.sorted().map(::JsonPrimitive)), + ) + ) + }) + out.resolve("report.json").writeText(json.toString()) + val counts = all.groupingBy { it.outcome }.eachCount() + println("Results for ${all.size} extensions:") + Outcome.entries.forEach { println(" ${it.name}: ${counts[it] ?: 0}") } + all.maxByOrNull { it.convertPeakMb }?.let { println("Most heap to convert: ${it.convertPeakMb} MB, ${it.name}") } + println("Report: ${out.resolve("report.json").toAbsolutePath()}") + work.toFile().deleteRecursively() + APIHolder.allProviders.clear() + PluginManager.installed = { emptyList() } + hardLimitThreads.shutdownNow() + } + + companion object { + private const val SEARCH_LIMIT_MS = 30_000L + private const val LOAD_LIMIT_MS = 60_000L + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt new file mode 100644 index 00000000000..5879d383005 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt @@ -0,0 +1,298 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.plugins.PluginManager +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayOutputStream +import java.net.InetSocketAddress +import java.nio.file.Files +import kotlin.io.path.readBytes +import java.nio.file.Path +import java.security.MessageDigest +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import kotlin.io.path.exists +import kotlin.io.path.writeBytes +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PluginTest { + private val dir: Path = Files.createTempDirectory("cs-plugins") + private var server: HttpServer? = null + + @AfterTest + fun cleanup() { + server?.stop(0) + dir.toFile().deleteRecursively() + } + + private fun classBytes(name: String): ByteArray = + javaClass.classLoader.getResourceAsStream(name.replace('.', '/') + ".class")!!.use { it.readBytes() } + + /** A jar laid out like the ones the gradle plugin publishes: classes only, no manifest.json */ + private fun pluginJar(vararg classes: String, manifest: String? = null): ByteArray { + val out = ByteArrayOutputStream() + JarOutputStream(out).use { jar -> + for (name in classes) { + jar.putNextEntry(JarEntry(name.replace('.', '/') + ".class")) + jar.write(classBytes(name)) + } + if (manifest != null) { + jar.putNextEntry(JarEntry("manifest.json")) + jar.write(manifest.toByteArray()) + } + } + return out.toByteArray() + } + + private fun writeJar(bytes: ByteArray): Path = dir.resolve("plugin-${System.nanoTime()}.jar").also { it.writeBytes(bytes) } + + private val pluginClasses = arrayOf(NotAPlugin::class.java.name, TestPlugin::class.java.name, TestProvider::class.java.name) + + @Test + fun findsThePluginClassByItsAnnotation() { + val jar = writeJar(pluginJar(*pluginClasses)) + assertEquals(TestPlugin::class.java.name, PluginLoader.findPluginClassName(jar)) + } + + @Test + fun manifestJsonWinsWhenPresent() { + val jar = writeJar(pluginJar(*pluginClasses, manifest = """{"pluginClassName":"some.Other"}""")) + assertEquals("some.Other", PluginLoader.findPluginClassName(jar)) + } + + @Test + fun jarWithoutAPluginIsRejected() { + val jar = writeJar(pluginJar(NotAPlugin::class.java.name)) + assertFailsWith { PluginLoader.load(jar) } + } + + @Test + fun loadRegistersProvidersAndUnloadRemovesThem() { + val jar = writeJar(pluginJar(*pluginClasses)) + val before = TestPlugin.unloads + + val plugin = PluginLoader.load(jar) + assertEquals(listOf("Desktop Test Provider"), plugin.providers.map { it.name }) + assertNotNull(APIHolder.getApiFromNameNull("Desktop Test Provider")) + + plugin.unload() + assertEquals(before + 1, TestPlugin.unloads) + assertTrue(plugin.providers.isEmpty()) + assertTrue(APIHolder.apis.none { it.name == "Desktop Test Provider" }) + // The class loader is closed, so Windows lets the jar be deleted + Files.delete(jar) + } + + @Test + fun parsesPluginListsWithAndWithoutDesktopJars() { + val plugins = RepositoryClient.parsePluginList( + """[ + {"internalName":"A","name":"A","version":3,"status":1,"authors":["x"],"jarUrl":"https://h/A.jar","jarHash":"sha256-00","jarFileSize":10}, + {"internalName":"B","name":"B","version":1,"status":0,"url":"https://h/B.cs3"}, + {"name":"no internal name"} + ]""" + ) + assertEquals(listOf("A", "B"), plugins.map { it.internalName }) + assertTrue(plugins[0].supportsDesktop) + assertFalse(plugins[1].supportsDesktop) + assertEquals(10L, plugins[0].jarFileSize) + } + + @Test + fun aPluginNameThatIsNotASafeFileNameIsSkipped() { + val names = listOf("../../evil", "C:/Users/Public/evil", "..", "a b", "ok.Name_1-2") + val plugins = RepositoryClient.parsePluginList( + names.joinToString(",", "[", "]") { """{"internalName":"$it","name":"x","version":1,"jarUrl":"https://h/x.jar"}""" } + ) + assertEquals(listOf("ok.Name_1-2"), plugins.map { it.internalName }) + } + + /** Serves repo.json, plugins.json and the jar from a local server, like a GitHub repository */ + private fun serveRepository(jar: ByteArray, jarHash: String = sha256(jar)): String { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).also { this.server = it } + val base = "http://127.0.0.1:${server.address.port}" + val files = mapOf( + "/repo.json" to """{"name":"Local test repo","manifestVersion":1,"pluginLists":["$base/plugins.json"]}""".toByteArray(), + "/plugins.json" to """[{"internalName":"TestPlugin","name":"Test plugin","version":2,"status":1, + "jarUrl":"$base/TestPlugin.jar","jarHash":"$jarHash","jarFileSize":${jar.size}}]""".toByteArray(), + "/TestPlugin.jar" to jar, + "/Broken.cs3" to "not an Android extension".toByteArray(), + ) + server.createContext("/") { exchange -> + val body = files[exchange.requestURI.path] + exchange.sendResponseHeaders(if (body == null) 404 else 200, body?.size?.toLong() ?: -1) + body?.let { exchange.responseBody.write(it) } + exchange.close() + } + server.start() + return "$base/repo.json" + } + + private fun sha256(bytes: ByteArray) = + "sha256-" + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + + @Test + fun partsThatCouldNotBeConvertedAreKeptAcrossRestarts() { + val plugins = dir.resolve("plugins").also { Files.createDirectories(it) } + plugins.resolve("installed.json").writeBytes( + """[{"internalName":"Big","name":"Big","version":1,"repositoryUrl":"https://h/repo.json","isAndroid":true, + "convertedWith":${DesktopPluginManager.CONVERTER_VERSION},"disabledMethods":2}, + {"internalName":"Old","name":"Old","version":1,"repositoryUrl":"https://h/repo.json"}]""".toByteArray(), + ) + val manager = DesktopPluginManager(plugins, RepositoryClient()) + // Saved by an older app, without the field: nothing disabled + assertEquals(mapOf("Big" to 2, "Old" to 0), manager.installed.value.associate { it.internalName to it.disabledMethods }) + } + + @Test + fun installFromRepositoryThenRestartThenUninstall() = runBlocking { + val repoUrl = serveRepository(pluginJar(*pluginClasses)) + val client = RepositoryClient() + val repo = client.fetchRepository(repoUrl) + assertEquals("Local test repo", repo.name) + val plugin = client.fetchPlugins(repo).single() + + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + manager.install(repoUrl, plugin) + assertEquals(listOf("TestPlugin"), manager.installed.value.map { it.internalName }) + assertNotNull(manager.loadedPlugin("TestPlugin")) + assertNotNull(APIHolder.getApiFromNameNull("Desktop Test Provider")) + + // A new manager on the same folder is what the next app start sees + manager.loadedPlugin("TestPlugin")!!.unload() + val restarted = DesktopPluginManager(dir.resolve("plugins"), client) + assertEquals(2, restarted.installed.value.single().version) + // Like Android, extensions see the file they came from, kept across restarts + assertEquals(listOf("${repoUrl.removeSuffix("repo.json")}TestPlugin.jar"), PluginManager.getPluginsOnline().map { it.url }) + restarted.loadAll() + assertNotNull(restarted.loadedPlugin("TestPlugin")) + + restarted.uninstall("TestPlugin") + assertTrue(restarted.installed.value.isEmpty()) + assertFalse(dir.resolve("plugins/TestPlugin.jar").exists()) + assertTrue(APIHolder.apis.none { it.name == "Desktop Test Provider" }) + } + + @Test + fun anUpdateThatFailsToConvertKeepsTheOldVersionRunning() = runBlocking { + val repoUrl = serveRepository(pluginJar(*pluginClasses)) + val client = RepositoryClient() + val plugin = client.fetchPlugins(client.fetchRepository(repoUrl)).single() + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + manager.install(repoUrl, plugin) + val jar = dir.resolve("plugins/TestPlugin.jar") + val before = jar.readBytes() + + // Version 3 is only an Android build, and not a readable one + val broken = plugin.copy(version = 3, jarUrl = null, jarHash = null, jarFileSize = null, androidUrl = repoUrl.replace("repo.json", "Broken.cs3")) + assertFailsWith { manager.install(repoUrl, broken) } + assertNotNull(manager.loadedPlugin("TestPlugin"), "the old version is still loaded") + assertNotNull(APIHolder.getApiFromNameNull("Desktop Test Provider")) + assertEquals(2, manager.installed.value.single().version) + assertTrue(before.contentEquals(jar.readBytes())) + assertTrue(Files.list(dir.resolve("plugins")).use { files -> files.noneMatch { it.fileName.toString().endsWith(".new") } }) + manager.uninstall("TestPlugin") + } + + @Test + fun downloadWithTheWrongHashIsRefused() = runBlocking { + val repoUrl = serveRepository(pluginJar(*pluginClasses), jarHash = "sha256-" + "0".repeat(64)) + val client = RepositoryClient() + val plugin = client.fetchPlugins(client.fetchRepository(repoUrl)).single() + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + + assertFailsWith { manager.install(repoUrl, plugin) } + assertTrue(manager.installed.value.isEmpty()) + } + + /** A short link site: known codes redirect to a repository, unknown ones to its 404 page */ + private fun serveShortLinks(): String { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).also { this.server = it } + val base = "http://127.0.0.1:${server.address.port}" + server.createContext("/") { exchange -> + val location = when (exchange.requestURI.path) { + "/megarepo" -> "https://raw.githubusercontent.com/self-similarity/MegaRepo/builds/repo.json" + "/home" -> "$base/" + else -> "$base/404" + } + exchange.responseHeaders.add("Location", location) + exchange.sendResponseHeaders(301, -1) + exchange.close() + } + server.start() + return "$base/" + } + + @Test + fun repositoryShortCodesAndLinksResolveLikeAndroid() = runBlocking { + val shortLinks = serveShortLinks() + val client = RepositoryClient(shortLinkHost = shortLinks, bangShortLinkHost = shortLinks) + + assertEquals("https://raw.githubusercontent.com/self-similarity/MegaRepo/builds/repo.json", client.resolveRepositoryUrl(" megarepo ")) + assertEquals("https://raw.githubusercontent.com/self-similarity/MegaRepo/builds/repo.json", client.resolveRepositoryUrl("!megarepo")) + assertEquals(null, client.resolveRepositoryUrl("unknown-code")) + assertEquals(null, client.resolveRepositoryUrl("home")) + assertEquals(null, client.resolveRepositoryUrl("not a code")) + + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("https://example.com/repo.json")) + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("cloudstreamrepo://example.com/repo.json")) + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("https://cs.repo/?https://example.com/repo.json")) + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("https://cs.repo/example.com/repo.json")) + } + + @Test + fun communityRepositoryListTakesBothEntryForms() = runBlocking { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).also { this@PluginTest.server = it } + val body = """["https://a/repo.json", {"url":"https://b/repo.json","verified":true}, {"name":"no url"}, "https://a/repo.json"]""".toByteArray() + server.createContext("/repos-db.json") { it.sendResponseHeaders(200, body.size.toLong()); it.responseBody.write(body); it.close() } + server.start() + + val repos = RepositoryClient().fetchCommunityRepositories("http://127.0.0.1:${server.address.port}/repos-db.json") + assertEquals(listOf("https://a/repo.json", "https://b/repo.json"), repos) + } + + /** + * Resolves the real megarepo short code and reads the real community list. + * Needs the internet, so it only runs with CLOUDSTREAM_ONLINE_TESTS=1. + */ + @Test + fun megarepoShortCodeAndCommunityListOnline() = runBlocking { + if (System.getenv("CLOUDSTREAM_ONLINE_TESTS") != "1") return@runBlocking println("Skipped, set CLOUDSTREAM_ONLINE_TESTS=1") + val client = RepositoryClient() + val url = client.resolveRepositoryUrl("megarepo") + println("megarepo -> $url") + val plugins = client.fetchPlugins(client.fetchRepository(url!!)) + assertEquals(listOf(RepositoryClient.MEGA_REPO_PLUGIN), plugins.map { it.internalName }) + val community = client.fetchCommunityRepositories() + println("${community.size} community repositories") + assertTrue(community.size > 10) + } + + /** + * Installs a real plugin from the official repository and searches with it. + * Needs the internet, so it only runs with CLOUDSTREAM_ONLINE_TESTS=1. + */ + @Test + fun officialRepositoryPluginReturnsSearchResults() = runBlocking { + if (System.getenv("CLOUDSTREAM_ONLINE_TESTS") != "1") return@runBlocking println("Skipped, set CLOUDSTREAM_ONLINE_TESTS=1") + val client = RepositoryClient() + val repo = client.fetchRepository(RepositoryClient.OFFICIAL_REPOSITORY) + val plugin = client.fetchPlugins(repo).first { it.internalName == "InternetArchiveProvider" } + + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + manager.install(repo.url, plugin) + val provider = manager.loadedPlugin(plugin.internalName)!!.providers.single() + // The paged search is what the app calls, providers may implement only that one + val results = provider.search("nosferatu", 1)?.items.orEmpty() + println("${provider.name} returned ${results.size} results, first: ${results.firstOrNull()?.name}") + assertTrue(results.isNotEmpty(), "Expected search results from ${provider.name}") + manager.uninstall(plugin.internalName) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/TestPlugin.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/TestPlugin.kt new file mode 100644 index 00000000000..4e44fc28fe3 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/TestPlugin.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.plugins.BasePlugin +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin + +class TestProvider : MainAPI() { + override var name = "Desktop Test Provider" + override var mainUrl = "https://example.invalid" +} + +/** Packed into a jar by the tests, the same way a real cross-platform plugin is published */ +@CloudstreamPlugin +class TestPlugin : BasePlugin() { + override fun load() { + registerMainAPI(TestProvider()) + loads++ + } + + override fun beforeUnload() { + unloads++ + } + + companion object { + var loads = 0 + var unloads = 0 + } +} + +/** Has no annotation, so it must never be picked as the plugin class */ +class NotAPlugin diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistoryTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistoryTest.kt new file mode 100644 index 00000000000..8e9d7178228 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistoryTest.kt @@ -0,0 +1,63 @@ +package com.lagradost.cloudstream4.search + +import android.widget.Toast +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DataStoreSearchHistoryTest { + private val dir = Files.createTempDirectory("cs-search") + + init { + DesktopAndroid.init(dir) + } + + @AfterTest + fun cleanup() { + DesktopAndroid.toastHandler = null + dir.toFile().deleteRecursively() + } + + @Test + fun savesInTheAndroidAppsKeyAndReadsBack() { + var now = 0L + val history = DataStoreSearchHistory(clock = { ++now }) + history.add("dune") + history.add("alien") + history.add("d") + + // A new store reads what the last one wrote, as after a restart + val again = DataStoreSearchHistory() + assertEquals(listOf("alien", "dune"), again.entries().map { it.query }) + assertEquals("dune".hashCode().toString(), again.entries().last().key) + + val file = dir.resolve("shared_prefs/rebuild_preference.json") + val key = "0/search_history/${"dune".hashCode()}" + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline && !(Files.exists(file) && key in file.readText())) Thread.sleep(50) + assertTrue(key in file.readText(), "saved under the Android app's key") + + again.remove("dune".hashCode().toString()) + assertEquals(listOf("alien"), again.entries().map { it.query }) + again.clear() + assertTrue(again.entries().isEmpty()) + } + + @Test + fun extensionToastsReachTheApp() { + val shown = mutableListOf() + DesktopAndroid.toastHandler = { shown += it } + + Toast.makeText(DesktopAndroid.application, "Logged in", Toast.LENGTH_LONG).show() + CommonActivity.showToast("Cookies saved") + // Android shows nothing for an empty toast + Toast.makeText(DesktopAndroid.application, "", Toast.LENGTH_SHORT).show() + + assertEquals(listOf("Logged in", "Cookies saved"), shown) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/torrent/TorrServerTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/torrent/TorrServerTest.kt new file mode 100644 index 00000000000..c30491d20e9 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/torrent/TorrServerTest.kt @@ -0,0 +1,49 @@ +package com.lagradost.cloudstream4.torrent + +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Downloads TorrServer (63 MB, kept in the build folder) and streams a public torrent, so it only + * runs with -Dcloudstream.torrentTests=true (gradle: -PtorrentTests) on Windows. + */ +class TorrServerTest { + private val enabled = System.getProperty("cloudstream.torrentTests") == "true" && TorrServer.supported + private val server = TorrServer(File(System.getProperty("cloudstream.home"), "torrserver")) + + @AfterTest + fun stop() = server.stop() + + @Test + fun streamsAMagnetLink() = runBlocking { + if (!enabled) return@runBlocking + server.install() + assertTrue(server.installed) + // Big Buck Bunny (CC BY 3.0), from WebTorrent's examples + val magnet = "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny" + + "&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexplodie.org%3A6969" + + "&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com" + + "&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F" + val stream = server.stream(newExtractorLink("Test", "Big Buck Bunny", magnet, ExtractorLinkType.MAGNET)) + println("Streaming ${stream.fileName} from ${stream.url}") + assertTrue(stream.fileName.endsWith(".mp4"), stream.fileName) + val http = OkHttpClient.Builder().readTimeout(120, TimeUnit.SECONDS).build() + http.newCall(Request.Builder().url(stream.url).header("Range", "bytes=0-1048575").build()).execute().use { response -> + assertEquals(206, response.code) + val bytes = response.body.bytes() + assertEquals(1_048_576, bytes.size) + // An MP4 starts with an "ftyp" box + assertEquals("ftyp", String(bytes, 4, 4)) + } + server.drop(stream.hash) + } +} diff --git a/shared/src/jvmTest/kotlin/com/mihon/common/preference/JvmPreferenceStoreTest.kt b/shared/src/jvmTest/kotlin/com/mihon/common/preference/JvmPreferenceStoreTest.kt new file mode 100644 index 00000000000..f85352d477a --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/mihon/common/preference/JvmPreferenceStoreTest.kt @@ -0,0 +1,130 @@ +package com.mihon.common.preference + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JvmPreferenceStoreTest { + private enum class Quality { HD, SD, CAM } + + private val dir: Path = Files.createTempDirectory("cs-prefs") + private val file: Path = dir.resolve("settings.json") + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + @Test + fun valuesSurviveARestart() { + JvmPreferenceStore(file).apply { + getString("s").set("hello") + getInt("i").set(42) + getLong("l").set(9_000_000_000L) + getFloat("f").set(1.5f) + getBoolean("b").set(true) + getStringSet("set").set(setOf("a", "b")) + getEnumSet("enum_set", emptySet()).set(setOf(Quality.HD, Quality.CAM)) + getObjectFromInt("obj_int", Quality.SD, { it.ordinal }, { Quality.entries[it] }).set(Quality.CAM) + } + + val reopened = JvmPreferenceStore(file) + assertEquals("hello", reopened.getString("s").get()) + assertEquals(42, reopened.getInt("i").get()) + assertEquals(9_000_000_000L, reopened.getLong("l").get()) + assertEquals(1.5f, reopened.getFloat("f").get()) + assertEquals(true, reopened.getBoolean("b").get()) + assertEquals(setOf("a", "b"), reopened.getStringSet("set").get()) + assertEquals(setOf(Quality.HD, Quality.CAM), reopened.getEnumSet("enum_set", emptySet()).get()) + assertEquals( + Quality.CAM, + reopened.getObjectFromInt("obj_int", Quality.SD, { it.ordinal }, { Quality.entries[it] }).get() + ) + } + + @Test + fun missingFileGivesDefaultsAndIsNotCreatedUntilAWrite() { + val store = JvmPreferenceStore(file) + assertEquals(7, store.getInt("i", 7).get()) + assertFalse(store.getInt("i", 7).isSet()) + assertFalse(file.exists()) + + store.getInt("i", 7).set(8) + assertTrue(file.exists()) + } + + @Test + fun corruptFileGivesDefaultsAndIsKeptForDebugging() { + file.writeText("{ not json") + val store = JvmPreferenceStore(file) + assertEquals("default", store.getString("s", "default").get()) + assertTrue(dir.resolve("settings.json.corrupt").exists()) + + store.getString("s").set("works") + assertEquals("works", JvmPreferenceStore(file).getString("s").get()) + } + + @Test + fun wrongTypeReadsAsDefault() { + val store = JvmPreferenceStore(file) + store.getString("key").set("not a number") + assertEquals(5, store.getInt("key", 5).get()) + assertEquals(setOf("x"), store.getStringSet("key", setOf("x")).get()) + } + + @Test + fun deleteRemovesTheValueFromDisk() { + val store = JvmPreferenceStore(file) + store.getBoolean("b").set(true) + store.getBoolean("b").delete() + + assertFalse(store.getBoolean("b").isSet()) + assertFalse(JvmPreferenceStore(file).getBoolean("b").isSet()) + } + + @Test + fun noTemporaryFileIsLeftBehind() { + val store = JvmPreferenceStore(file) + repeat(5) { store.getInt("i").set(it) } + assertEquals(listOf("settings.json"), dir.listDirectoryEntries().map { it.fileName.toString() }) + } + + @Test + fun getAllReturnsPlainValues() { + val store = JvmPreferenceStore(file) + store.getString("s").set("text") + store.getBoolean("b").set(true) + store.getStringSet("set").set(setOf("a")) + val all = store.getAll() + assertEquals("text", all["s"]) + assertEquals(true, all["b"]) + assertEquals(setOf("a"), all["set"]) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun changesEmitsTheCurrentValueThenEachUpdate() = runTest { + val store = JvmPreferenceStore(file) + val pref = store.getInt("i", 0) + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { pref.changes().toList(seen) } + + pref.set(1) + store.getInt("other").set(99) // Other keys must not emit + pref.set(2) + + assertEquals(listOf(0, 1, 2), seen) + } +}