From 460647a0cb27de47ed0b9cf0899f97f834184b9d Mon Sep 17 00:00:00 2001 From: atomic-crafter <64207317+atomic-crafter@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:15:36 +0100 Subject: [PATCH 1/5] Port CloudStream to desktop (macOS, Windows, Linux) - Compose Desktop app: home, search, results, library, downloads, extensions, settings - Loads extensions from repositories: JVM jars directly, Android .cs3 converted with dex2jar - Desktop shims for the Android and app APIs extensions use, placeholder classes for the rest - Built-in player using LibVLC, with a header proxy for streams, plus external player support - Offline downloads for direct and HLS links, with subtitles and resume - Native installers (dmg, msi, exe, deb, rpm) and a GitHub Actions workflow to build them --- .github/workflows/desktop.yml | 117 +++++ desktopApp/build.gradle.kts | 105 +++- desktopApp/src/desktop-icons/icon-linux.png | Bin 8334 -> 9654 bytes desktopApp/src/desktop-icons/icon-mac.icns | Bin 0 -> 49461 bytes .../jvmMain/composeResources/drawable/add.xml | 7 + .../composeResources/drawable/arrow_back.xml | 10 + .../composeResources/drawable/arrow_down.xml | 7 + .../composeResources/drawable/audio.xml | 11 + .../composeResources/drawable/bookmark.xml | 7 + .../drawable/bookmark_border.xml | 5 + .../composeResources/drawable/clock.xml | 9 + .../composeResources/drawable/delete.xml | 7 + .../composeResources/drawable/download.xml | 14 + .../composeResources/drawable/extension.xml | 5 + .../composeResources/drawable/forward.xml | 19 + .../composeResources/drawable/fullscreen.xml | 9 + .../drawable/fullscreen_exit.xml | 9 + .../composeResources/drawable/home.xml | 5 + .../composeResources/drawable/language.xml | 9 + .../composeResources/drawable/library.xml | 9 + .../composeResources/drawable/open_in_new.xml | 4 + .../composeResources/drawable/pause.xml | 5 + .../composeResources/drawable/play.xml | 5 + .../composeResources/drawable/refresh.xml | 9 + .../composeResources/drawable/replay.xml | 19 + .../composeResources/drawable/search.xml | 9 + .../composeResources/drawable/settings.xml | 5 + .../composeResources/drawable/skip_next.xml | 5 + .../drawable/skip_previous.xml | 11 + .../composeResources/drawable/sources.xml | 7 + .../composeResources/drawable/speed.xml | 5 + .../composeResources/drawable/star.xml | 5 + .../composeResources/drawable/subtitles.xml | 5 + .../composeResources/drawable/update.xml | 5 + .../composeResources/drawable/volume_mute.xml | 4 + .../composeResources/drawable/volume_up.xml | 4 + .../composeResources/drawable/warning.xml | 5 + .../jvmMain/kotlin/android/app/Activity.kt | 6 + .../kotlin/android/app/ActivityManager.kt | 23 + .../jvmMain/kotlin/android/content/Context.kt | 46 ++ .../kotlin/android/content/ContextWrapper.kt | 6 + .../android/content/SharedPreferences.kt | 29 ++ .../kotlin/android/content/res/Resources.kt | 7 + .../src/jvmMain/kotlin/android/util/Log.kt | 30 ++ .../lagradost/cloudstream3/CloudStreamApp.kt | 54 ++ .../lagradost/cloudstream3/CommonActivity.kt | 23 + .../lagradost/cloudstream3/MainActivity.kt | 11 + .../cloudstream3/network/CloudflareKiller.kt | 14 + .../lagradost/cloudstream3/plugins/Plugin.kt | 26 + .../cloudstream3/plugins/PluginCompat.kt | 33 ++ .../cloudstream3/plugins/RepositoryManager.kt | 41 ++ .../ui/settings/extensions/RepositoryData.kt | 21 + .../lagradost/cloudstream3/utils/DataStore.kt | 80 +++ .../com/lagradost/cloudstream3/utils/Event.kt | 26 + .../kotlin/com/lagradost/cloudstream4/Main.kt | 104 ++-- .../cloudstream4/desktop/data/AppData.kt | 151 ++++++ .../cloudstream4/desktop/data/AppDirs.kt | 42 ++ .../desktop/data/JsonFileStore.kt | 71 +++ .../desktop/data/JsonSharedPreferences.kt | 51 ++ .../desktop/downloads/DownloadManager.kt | 311 ++++++++++++ .../desktop/player/ExternalPlayer.kt | 60 +++ .../desktop/player/StreamProxy.kt | 142 ++++++ .../cloudstream4/desktop/player/VlcEngine.kt | 264 ++++++++++ .../desktop/plugins/DexConverter.kt | 130 +++++ .../desktop/plugins/ExtensionClassLoader.kt | 84 ++++ .../desktop/plugins/PluginManager.kt | 377 ++++++++++++++ .../lagradost/cloudstream4/desktop/ui/App.kt | 126 +++++ .../cloudstream4/desktop/ui/Components.kt | 256 ++++++++++ .../desktop/ui/DownloadsScreen.kt | 160 ++++++ .../desktop/ui/ExtensionSettings.kt | 128 +++++ .../desktop/ui/ExtensionsScreen.kt | 367 ++++++++++++++ .../cloudstream4/desktop/ui/HomeScreen.kt | 220 ++++++++ .../cloudstream4/desktop/ui/LibraryScreen.kt | 75 +++ .../cloudstream4/desktop/ui/Navigation.kt | 64 +++ .../cloudstream4/desktop/ui/PlayerScreen.kt | 473 ++++++++++++++++++ .../desktop/ui/PlayerViewModel.kt | 161 ++++++ .../cloudstream4/desktop/ui/ResultScreen.kt | 406 +++++++++++++++ .../cloudstream4/desktop/ui/SearchScreen.kt | 161 ++++++ .../cloudstream4/desktop/ui/SettingsScreen.kt | 161 ++++++ gradle/libs.versions.toml | 4 + 80 files changed, 5444 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/desktop.yml create mode 100644 desktopApp/src/desktop-icons/icon-mac.icns create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/add.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/arrow_back.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/arrow_down.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/audio.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/bookmark.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/bookmark_border.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/clock.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/delete.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/download.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/extension.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/forward.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/fullscreen.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/fullscreen_exit.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/home.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/language.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/library.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/open_in_new.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/pause.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/play.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/refresh.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/replay.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/search.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/settings.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/skip_next.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/skip_previous.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/sources.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/speed.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/star.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/subtitles.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/update.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/volume_mute.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/volume_up.xml create mode 100644 desktopApp/src/jvmMain/composeResources/drawable/warning.xml create mode 100644 desktopApp/src/jvmMain/kotlin/android/app/Activity.kt create mode 100644 desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/android/content/Context.kt create mode 100644 desktopApp/src/jvmMain/kotlin/android/content/ContextWrapper.kt create mode 100644 desktopApp/src/jvmMain/kotlin/android/content/SharedPreferences.kt create mode 100644 desktopApp/src/jvmMain/kotlin/android/content/res/Resources.kt create mode 100644 desktopApp/src/jvmMain/kotlin/android/util/Log.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginCompat.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/ui/settings/extensions/RepositoryData.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppDirs.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonFileStore.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonSharedPreferences.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/downloads/DownloadManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/ExternalPlayer.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/StreamProxy.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/ExtensionClassLoader.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Components.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/DownloadsScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionSettings.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionsScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/HomeScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/LibraryScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Navigation.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerViewModel.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ResultScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SearchScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000000..c297f23307b --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,117 @@ +name: Desktop Installers + +# Builds the desktop app installers for macOS, Windows and Linux. +# Push a tag like `desktop-v4.8.0` to also publish them as a GitHub release. +on: + workflow_dispatch: + push: + branches: [master] + tags: ["desktop-v*"] + paths: + - "desktopApp/**" + - "shared/**" + - "library/**" + - "gradle/**" + - ".github/workflows/desktop.yml" + pull_request: + paths: + - "desktopApp/**" + - "shared/**" + - ".github/workflows/desktop.yml" + +permissions: + contents: read + +jobs: + package: + strategy: + fail-fast: false + matrix: + include: + - name: macos-arm64 + os: macos-15 + tasks: packageDmg + - name: macos-x64 + os: macos-15-intel + tasks: packageDmg + - name: windows-x64 + os: windows-latest + tasks: packageMsi packageExe + - name: linux-x64 + os: ubuntu-latest + tasks: packageDeb packageRpm + + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + + # jpackage builds the installer with the JDK that runs Gradle, and bundles its runtime + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + - name: Install Linux packaging tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y rpm fakeroot + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: ${{ github.ref != 'refs/heads/master' }} + + - name: Build installers + shell: bash + run: | + chmod +x gradlew + tasks="" + for task in ${{ matrix.tasks }}; do tasks="$tasks :desktopApp:$task"; done + ./gradlew $tasks --no-configuration-cache + + - name: Upload installers + uses: actions/upload-artifact@v7 + with: + name: CloudStream-${{ matrix.name }} + path: | + desktopApp/build/compose/binaries/main/dmg/*.dmg + desktopApp/build/compose/binaries/main/msi/*.msi + desktopApp/build/compose/binaries/main/exe/*.exe + desktopApp/build/compose/binaries/main/deb/*.deb + desktopApp/build/compose/binaries/main/rpm/*.rpm + if-no-files-found: error + + release: + if: startsWith(github.ref, 'refs/tags/desktop-v') + needs: package + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v5 + with: + path: installers + + # Installers from different architectures share a file name, so prefix them with the build name + - name: Name installers by platform + run: | + mkdir release + for dir in installers/*/; do + name=$(basename "$dir") + find "$dir" -type f | while read -r file; do + cp "$file" "release/${name}-$(basename "$file")" + done + done + ls -la release + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: release/* + generate_release_notes: true + body: | + Unsigned builds. VLC must be installed for the built-in player. + macOS: if it says the app is damaged, run `xattr -cr /Applications/CloudStream.app`. + Windows: SmartScreen may warn, choose "More info" → "Run anyway". diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 539c51d36f5..5f9046566bd 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -9,6 +9,12 @@ plugins { kotlin { jvm {} sourceSets { + all { + languageSettings { + optIn("com.lagradost.cloudstream3.InternalAPI") + optIn("com.lagradost.cloudstream3.Prerelease") + } + } jvmMain.dependencies { implementation(libs.bundles.compose) implementation(libs.kotlinx.coroutines.core) @@ -20,40 +26,111 @@ kotlin { } implementation(project(":shared")) implementation(project(":library")) + + // The library only exposes these as implementation details, but we use them directly + implementation(libs.nicehttp) + implementation(libs.jackson.module.kotlin) + implementation(libs.kotlin.reflect) + implementation(libs.coil.network.ktor3) + implementation(libs.ktor.client.java) + + // Video playback through the system LibVLC + implementation(libs.vlcj) + // Converts Android-only (.cs3 / dex) extensions to JVM bytecode + implementation(libs.dex2jar.translator) } } } -// java.lang.System::load has been called by org.jetbrains.skiko.LibraryLoader in an unnamed module +/** + * Extensions are built for Android and reference Android classes in code paths desktop never runs + * (settings screens). The bytecode verifier would reject those classes up front, so it is turned off + * for classes outside the JDK, missing classes then only fail if that code actually runs. + */ +val extensionJvmArgs = arrayOf("-XX:+UnlockDiagnosticVMOptions", "-XX:-BytecodeVerificationRemote") + tasks.withType { - jvmArgs("--enable-native-access=ALL-UNNAMED") + // java.lang.System::load has been called by org.jetbrains.skiko.LibraryLoader in an unnamed module + jvmArgs("--enable-native-access=ALL-UNNAMED", *extensionJvmArgs) +} + +compose.resources { + packageOfResClass = "com.lagradost.cloudstream4.desktop.generated.resources" + generateResClass = always } +val appVersion = libs.versions.versionName.get() + compose.desktop { application { mainClass = "com.lagradost.cloudstream4.MainKt" + jvmArgs += listOf( + "--enable-native-access=ALL-UNNAMED", + "-Xmx2G", + *extensionJvmArgs, + ) + nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + targetFormats( + TargetFormat.Dmg, + TargetFormat.Msi, + TargetFormat.Exe, + TargetFormat.Deb, + TargetFormat.Rpm, + ) packageName = "CloudStream" - packageVersion = "1.0.0" + // Native installers require MAJOR.MINOR.PATCH with MAJOR > 0 + packageVersion = appVersion + description = "Stream and browse media using CloudStream extensions" + vendor = "recloudstream" + copyright = "GPL-3.0" + licenseFile.set(rootProject.file("LICENSE")) - val iconsRoot = project.file("desktop-icons") + // From suggestRuntimeModules, plus what extensions commonly need at runtime + // (DNS, XML, every charset and locale for parsing sites, extra crypto) + modules( + "java.instrument", + "java.management", + "java.naming", + "java.net.http", + "java.prefs", + "java.scripting", + "java.security.jgss", + "java.sql", + "java.xml", + "jdk.charsets", + "jdk.crypto.ec", + "jdk.dynalink", + "jdk.httpserver", + "jdk.localedata", + "jdk.net", + "jdk.unsupported", + "jdk.zipfs", + ) + + val iconsRoot = project.file("src/desktop-icons") macOS { - // iconFile.set(iconsRoot.resolve("icon-mac.icns")) + bundleID = "com.lagradost.cloudstream4" + iconFile.set(iconsRoot.resolve("icon-mac.icns")) + appCategory = "public.app-category.entertainment" } windows { iconFile.set(iconsRoot.resolve("icon-windows.ico")) - // menuGroup = "Compose Examples" - // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html - // upgradeUuid = "" + menuGroup = "CloudStream" + shortcut = true + dirChooser = true + perUserInstall = true + // Must stay the same between versions for upgrades to replace the old install + upgradeUuid = "8d2b7a3e-4d6c-4f1e-9a57-3c0f3e9b6a21" } linux { iconFile.set(iconsRoot.resolve("icon-linux.png")) + packageName = "cloudstream" + debMaintainer = "cloudstream@users.noreply.github.com" + menuGroup = "AudioVideo" + appCategory = "Video" + shortcut = true } } - - //buildTypes.release.proguard { - // configurationFiles.from(project.file("rules.pro")) - //} } -} \ No newline at end of file +} diff --git a/desktopApp/src/desktop-icons/icon-linux.png b/desktopApp/src/desktop-icons/icon-linux.png index 394459001fdb5237f04624560e66550660044d05..d959673a73e9e18358b58f2e096ee9a40a1086ab 100644 GIT binary patch literal 9654 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 delta 8277 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�f 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 Do*~v^ literal 0 HcmV?d00001 diff --git a/desktopApp/src/jvmMain/composeResources/drawable/add.xml b/desktopApp/src/jvmMain/composeResources/drawable/add.xml new file mode 100644 index 00000000000..fc7dc90f318 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/add.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/desktopApp/src/jvmMain/composeResources/drawable/arrow_back.xml b/desktopApp/src/jvmMain/composeResources/drawable/arrow_back.xml new file mode 100644 index 00000000000..196b4c087f9 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/arrow_down.xml b/desktopApp/src/jvmMain/composeResources/drawable/arrow_down.xml new file mode 100644 index 00000000000..2fd52cec035 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/arrow_down.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/desktopApp/src/jvmMain/composeResources/drawable/audio.xml b/desktopApp/src/jvmMain/composeResources/drawable/audio.xml new file mode 100644 index 00000000000..4685b20b4e4 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/audio.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/bookmark.xml b/desktopApp/src/jvmMain/composeResources/drawable/bookmark.xml new file mode 100644 index 00000000000..98c864c8be8 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/bookmark.xml @@ -0,0 +1,7 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/bookmark_border.xml b/desktopApp/src/jvmMain/composeResources/drawable/bookmark_border.xml new file mode 100644 index 00000000000..45309fb9a29 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/bookmark_border.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/clock.xml b/desktopApp/src/jvmMain/composeResources/drawable/clock.xml new file mode 100644 index 00000000000..3436c6208ae --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/clock.xml @@ -0,0 +1,9 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/delete.xml b/desktopApp/src/jvmMain/composeResources/drawable/delete.xml new file mode 100644 index 00000000000..77c9812159f --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/delete.xml @@ -0,0 +1,7 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/download.xml b/desktopApp/src/jvmMain/composeResources/drawable/download.xml new file mode 100644 index 00000000000..9284fcf4fa2 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/download.xml @@ -0,0 +1,14 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/extension.xml b/desktopApp/src/jvmMain/composeResources/drawable/extension.xml new file mode 100644 index 00000000000..9a51f9d8f11 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/extension.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/forward.xml b/desktopApp/src/jvmMain/composeResources/drawable/forward.xml new file mode 100644 index 00000000000..f5ff4c02757 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/forward.xml @@ -0,0 +1,19 @@ + + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/fullscreen.xml b/desktopApp/src/jvmMain/composeResources/drawable/fullscreen.xml new file mode 100644 index 00000000000..588c0e26286 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/fullscreen.xml @@ -0,0 +1,9 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/fullscreen_exit.xml b/desktopApp/src/jvmMain/composeResources/drawable/fullscreen_exit.xml new file mode 100644 index 00000000000..0f6b5ab247b --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/fullscreen_exit.xml @@ -0,0 +1,9 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/home.xml b/desktopApp/src/jvmMain/composeResources/drawable/home.xml new file mode 100644 index 00000000000..ab288b65cc1 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/home.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/language.xml b/desktopApp/src/jvmMain/composeResources/drawable/language.xml new file mode 100644 index 00000000000..46d21ff4f34 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/language.xml @@ -0,0 +1,9 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/library.xml b/desktopApp/src/jvmMain/composeResources/drawable/library.xml new file mode 100644 index 00000000000..a6d46a08da5 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/library.xml @@ -0,0 +1,9 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/open_in_new.xml b/desktopApp/src/jvmMain/composeResources/drawable/open_in_new.xml new file mode 100644 index 00000000000..2fdbae24a94 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/open_in_new.xml @@ -0,0 +1,4 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/pause.xml b/desktopApp/src/jvmMain/composeResources/drawable/pause.xml new file mode 100644 index 00000000000..bf2d88f6a33 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/pause.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/play.xml b/desktopApp/src/jvmMain/composeResources/drawable/play.xml new file mode 100644 index 00000000000..e4a9903d6c8 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/play.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/refresh.xml b/desktopApp/src/jvmMain/composeResources/drawable/refresh.xml new file mode 100644 index 00000000000..e61dcf1ce75 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/replay.xml b/desktopApp/src/jvmMain/composeResources/drawable/replay.xml new file mode 100644 index 00000000000..e57946b65e2 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/replay.xml @@ -0,0 +1,19 @@ + + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/search.xml b/desktopApp/src/jvmMain/composeResources/drawable/search.xml new file mode 100644 index 00000000000..3b78ed8b9e1 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/search.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/desktopApp/src/jvmMain/composeResources/drawable/settings.xml b/desktopApp/src/jvmMain/composeResources/drawable/settings.xml new file mode 100644 index 00000000000..63de28bb4ed --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/settings.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/skip_next.xml b/desktopApp/src/jvmMain/composeResources/drawable/skip_next.xml new file mode 100644 index 00000000000..80a90e614c1 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/skip_next.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/skip_previous.xml b/desktopApp/src/jvmMain/composeResources/drawable/skip_previous.xml new file mode 100644 index 00000000000..f4799baaf71 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/skip_previous.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/sources.xml b/desktopApp/src/jvmMain/composeResources/drawable/sources.xml new file mode 100644 index 00000000000..b3481d73b44 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/sources.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/speed.xml b/desktopApp/src/jvmMain/composeResources/drawable/speed.xml new file mode 100644 index 00000000000..42571f6a300 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/speed.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/star.xml b/desktopApp/src/jvmMain/composeResources/drawable/star.xml new file mode 100644 index 00000000000..9e19ac117f3 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/star.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/subtitles.xml b/desktopApp/src/jvmMain/composeResources/drawable/subtitles.xml new file mode 100644 index 00000000000..eb680b8c12b --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/subtitles.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/update.xml b/desktopApp/src/jvmMain/composeResources/drawable/update.xml new file mode 100644 index 00000000000..cd897b04655 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/update.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/volume_mute.xml b/desktopApp/src/jvmMain/composeResources/drawable/volume_mute.xml new file mode 100644 index 00000000000..975a6a1e83b --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/volume_mute.xml @@ -0,0 +1,4 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/volume_up.xml b/desktopApp/src/jvmMain/composeResources/drawable/volume_up.xml new file mode 100644 index 00000000000..eba4e23e8ff --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/volume_up.xml @@ -0,0 +1,4 @@ + + + diff --git a/desktopApp/src/jvmMain/composeResources/drawable/warning.xml b/desktopApp/src/jvmMain/composeResources/drawable/warning.xml new file mode 100644 index 00000000000..2dcf7c251a8 --- /dev/null +++ b/desktopApp/src/jvmMain/composeResources/drawable/warning.xml @@ -0,0 +1,5 @@ + + + diff --git a/desktopApp/src/jvmMain/kotlin/android/app/Activity.kt b/desktopApp/src/jvmMain/kotlin/android/app/Activity.kt new file mode 100644 index 00000000000..cc2b91c3880 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/app/Activity.kt @@ -0,0 +1,6 @@ +package android.app + +import android.content.ContextWrapper + +/** Stand-in so `CommonActivity.activity` has a type, there is never an Activity on desktop */ +open class Activity : ContextWrapper() diff --git a/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt b/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt new file mode 100644 index 00000000000..eb6bee406a4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt @@ -0,0 +1,23 @@ +package android.app + +import java.lang.management.ManagementFactory + +/** Only the memory query, extensions use it to scale how many requests they run in parallel */ +open class ActivityManager { + open class MemoryInfo { + @JvmField var availMem: Long = 0 + @JvmField var totalMem: Long = 0 + @JvmField var threshold: Long = 0 + @JvmField var lowMemory: Boolean = false + } + + open fun getMemoryInfo(outInfo: MemoryInfo) { + val os = ManagementFactory.getOperatingSystemMXBean() as? com.sun.management.OperatingSystemMXBean + outInfo.totalMem = os?.totalMemorySize ?: Runtime.getRuntime().maxMemory() + outInfo.availMem = os?.freeMemorySize ?: Runtime.getRuntime().freeMemory() + outInfo.threshold = outInfo.totalMem / 20 + outInfo.lowMemory = outInfo.availMem < outInfo.threshold + } + + open fun isLowRamDevice(): Boolean = false +} diff --git a/desktopApp/src/jvmMain/kotlin/android/content/Context.kt b/desktopApp/src/jvmMain/kotlin/android/content/Context.kt new file mode 100644 index 00000000000..3e00aa1ab65 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/content/Context.kt @@ -0,0 +1,46 @@ +@file:Suppress("unused") + +package android.content + +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.data.JsonSharedPreferences +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Desktop stand-in for android.content.Context. + * + * Extensions built only for Android override `Plugin.load(context: Context)`, so this class must exist + * with the same name. Only the parts extensions realistically touch during load are implemented. + */ +open class Context { + open fun getFilesDir(): File = AppDirs.dataDir + open fun getCacheDir(): File = AppDirs.cacheDir + open fun getApplicationContext(): Context = this + open fun getPackageName(): String = "com.lagradost.cloudstream3" + + /** Only [ACTIVITY_SERVICE] exists on desktop, other services (keyboard, clipboard...) are Android UI */ + open fun getSystemService(name: String): Any? = when (name) { + ACTIVITY_SERVICE -> activityManager + else -> null + } + + open fun getSharedPreferences(name: String, mode: Int): SharedPreferences = + preferences.getOrPut(name) { JsonSharedPreferences(File(preferencesDir, "$name.json")) } + + companion object { + const val MODE_PRIVATE = 0 + const val ACTIVITY_SERVICE = "activity" + private val activityManager by lazy { android.app.ActivityManager() } + private val preferences = ConcurrentHashMap() + private val preferencesDir get() = File(AppDirs.dataDir, "prefs") + + /** Names of every preference store extensions have created, for the settings editor */ + fun preferenceNames(): List = + (preferencesDir.listFiles { f -> f.extension == "json" }.orEmpty().map { it.nameWithoutExtension } + preferences.keys) + .distinct().sorted() + } +} + +/** The single context handed to every extension on desktop. */ +object DesktopContext : Context() diff --git a/desktopApp/src/jvmMain/kotlin/android/content/ContextWrapper.kt b/desktopApp/src/jvmMain/kotlin/android/content/ContextWrapper.kt new file mode 100644 index 00000000000..461baeb09fe --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/content/ContextWrapper.kt @@ -0,0 +1,6 @@ +package android.content + +/** Android's Activity extends ContextWrapper, kept so the class hierarchy extensions expect exists */ +open class ContextWrapper : Context() { + open fun getBaseContext(): Context = DesktopContext +} diff --git a/desktopApp/src/jvmMain/kotlin/android/content/SharedPreferences.kt b/desktopApp/src/jvmMain/kotlin/android/content/SharedPreferences.kt new file mode 100644 index 00000000000..96f9d41a627 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/content/SharedPreferences.kt @@ -0,0 +1,29 @@ +@file:Suppress("unused") + +package android.content + +/** Subset of android.content.SharedPreferences used by extensions for their settings. */ +interface SharedPreferences { + fun getAll(): Map + fun getString(key: String, defValue: String?): String? + fun getStringSet(key: String, defValues: Set?): Set? + 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 + operator fun contains(key: String): Boolean + fun edit(): Editor + + interface Editor { + fun putString(key: String, value: String?): Editor + fun putStringSet(key: String, values: Set?): 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() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/android/content/res/Resources.kt b/desktopApp/src/jvmMain/kotlin/android/content/res/Resources.kt new file mode 100644 index 00000000000..012a5bb806c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/content/res/Resources.kt @@ -0,0 +1,7 @@ +package android.content.res + +/** + * Placeholder for Android resources. Extension resources (`requiresResources`) are not supported on + * desktop, so this is never populated, but it must exist for [com.lagradost.cloudstream3.plugins.Plugin]. + */ +open class Resources diff --git a/desktopApp/src/jvmMain/kotlin/android/util/Log.kt b/desktopApp/src/jvmMain/kotlin/android/util/Log.kt new file mode 100644 index 00000000000..2fab1658b66 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/util/Log.kt @@ -0,0 +1,30 @@ +@file:Suppress("unused") + +package android.util + +/** + * Minimal stand-in for android.util.Log so extensions compiled against Android can log on desktop. + */ +object Log { + private fun print(level: String, tag: String?, msg: String?, tr: Throwable? = null): Int { + println("$level/$tag: $msg") + tr?.printStackTrace() + return 0 + } + + @JvmStatic fun v(tag: String?, msg: String?): Int = print("V", tag, msg) + @JvmStatic fun v(tag: String?, msg: String?, tr: Throwable?): Int = print("V", tag, msg, tr) + @JvmStatic fun d(tag: String?, msg: String?): Int = print("D", tag, msg) + @JvmStatic fun d(tag: String?, msg: String?, tr: Throwable?): Int = print("D", tag, msg, tr) + @JvmStatic fun i(tag: String?, msg: String?): Int = print("I", tag, msg) + @JvmStatic fun i(tag: String?, msg: String?, tr: Throwable?): Int = print("I", tag, msg, tr) + @JvmStatic fun w(tag: String?, msg: String?): Int = print("W", tag, msg) + @JvmStatic fun w(tag: String?, msg: String?, tr: Throwable?): Int = print("W", tag, msg, tr) + @JvmStatic fun w(tag: String?, tr: Throwable?): Int = print("W", tag, null, tr) + @JvmStatic fun e(tag: String?, msg: String?): Int = print("E", tag, msg) + @JvmStatic fun e(tag: String?, msg: String?, tr: Throwable?): Int = print("E", tag, msg, tr) + @JvmStatic fun wtf(tag: String?, msg: String?): Int = print("WTF", tag, msg) + + @JvmStatic + fun getStackTraceString(tr: Throwable?): String = tr?.stackTraceToString() ?: "" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt new file mode 100644 index 00000000000..5565d502e71 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,54 @@ +@file:Suppress("unused") + +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.DesktopContext +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.desktop.ui.openInBrowser + +/** Desktop copy of the Android Application class' companion, which extensions use for settings */ +class CloudStreamApp { + companion object { + tailrec fun Context.getActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> if (getBaseContext() === this) null else getBaseContext().getActivity() + else -> null + } + + // Kept void-returning setters explicit: extensions link against `(...)V` descriptors + val context: Context? get() = DesktopContext + + fun getKeyClass(path: String, valueType: Class): T? = context?.getKey(path, valueType) + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + fun removeKeys(folder: String): Int? = 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? = context?.getKey(path, defVal) + inline fun getKey(path: String): T? = context?.getKey(path) + inline fun getKey(folder: String, path: String): T? = context?.getKey(folder, path) + inline fun getKey(folder: String, path: String, defVal: T?): T? = + context?.getKey(folder, path, defVal) + + fun getKeys(folder: String): List? = context?.getKeys(folder) + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + fun removeKey(path: String) { + context?.removeKey(path) + } + fun openBrowser(url: String) = openInBrowser(url) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt new file mode 100644 index 00000000000..27286bf9be7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt @@ -0,0 +1,23 @@ +@file:Suppress("unused") + +package com.lagradost.cloudstream3 + +import android.app.Activity +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** Desktop copy of CommonActivity, toasts from extensions are shown as snackbars */ +object CommonActivity { + private val _toasts = MutableSharedFlow(extraBufferCapacity = 16) + val toasts: SharedFlow = _toasts.asSharedFlow() + + /** There is no Android activity on desktop */ + val activity: Activity? get() = null + + fun showToast(message: String?, duration: Int? = null) { + if (!message.isNullOrBlank()) _toasts.tryEmit(message) + } + + fun showToast(act: Activity?, message: String?, duration: Int? = null) = showToast(message, duration) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt new file mode 100644 index 00000000000..47210f2e1a2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -0,0 +1,11 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.utils.Event + +/** Only the static events of the Android MainActivity that extensions subscribe to */ +class MainActivity { + companion object { + /** Invoked with true once all extensions are loaded */ + val afterPluginsLoadedEvent = Event() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt new file mode 100644 index 00000000000..6f36a0859ef --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.network + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * On Android this solves Cloudflare challenges with a WebView. There is no WebView on desktop, + * so requests pass through unchanged and sites behind a challenge will fail as they would without it. + */ +class CloudflareKiller : Interceptor { + val savedCookies: MutableMap> = mutableMapOf() + + override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt new file mode 100644 index 00000000000..e777f6f09f5 --- /dev/null +++ b/desktopApp/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 + +/** + * Desktop copy of the Android app's Plugin class, with the same binary signature, so extensions + * that extend Plugin (rather than the cross-platform BasePlugin) can be loaded on desktop. + */ +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 not supported on desktop, this is always null. */ + var resources: Resources? = null + + /** Settings screens are Android views, so this is never invoked on desktop. */ + var openSettings: ((context: Context) -> Unit)? = null +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginCompat.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginCompat.kt new file mode 100644 index 00000000000..cb60e910a76 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginCompat.kt @@ -0,0 +1,33 @@ +@file:Suppress("unused") + +package com.lagradost.cloudstream3.plugins + +import com.lagradost.cloudstream4.desktop.data.AppData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.lagradost.cloudstream4.desktop.plugins.PluginManager as DesktopPluginManager + +/** Same shape as the Android app's PluginData */ +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 are known to call */ +object PluginManager { + private val scope = CoroutineScope(Dispatchers.IO) + + fun getPluginsOnline(): Array = AppData.plugins.value.map { + PluginData(it.internalName, null, true, it.filePath, it.version) + }.toTypedArray() + + fun getPluginsLocal(): Array = emptyArray() + + fun unloadPlugin(absolutePath: String) { + scope.launch { DesktopPluginManager.unload(absolutePath) } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt new file mode 100644 index 00000000000..191c52c3acc --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -0,0 +1,41 @@ +package com.lagradost.cloudstream3.plugins + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.RepositoryEntry +import com.lagradost.cloudstream4.desktop.plugins.PluginManager + +/** Same shape as the Android app's Repository, for extensions that read repositories themselves */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class Repository( + val iconUrl: String?, + val name: String, + val description: String?, + val manifestVersion: Int, + val pluginLists: List, +) + +/** + * Desktop stand-in for the Android app's RepositoryManager, backed by the desktop [PluginManager]. + * Only the functions extensions are known to call are provided, with the same binary signatures. + */ +@Suppress("unused") +object RepositoryManager { + suspend fun parseRepoUrl(url: String): String? = PluginManager.parseRepoUrl(url) + + suspend fun parseRepository(url: String): Repository? = PluginManager.fetchRepository(url)?.let { + Repository(it.iconUrl, it.name, it.description, it.manifestVersion, it.pluginLists) + } + + fun getRepositories(): Array = + AppData.repositories.value.map { RepositoryData(it.iconUrl, it.name, it.url) }.toTypedArray() + + suspend fun addRepository(repository: RepositoryData) { + AppData.addRepository(RepositoryEntry(repository.name, repository.url, repository.iconUrl)) + } + + suspend fun removeRepository(@Suppress("UNUSED_PARAMETER") context: android.content.Context, repository: RepositoryData) { + PluginManager.removeRepository(repository.url) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/ui/settings/extensions/RepositoryData.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/ui/settings/extensions/RepositoryData.kt new file mode 100644 index 00000000000..22a18beb3d2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/ui/settings/extensions/RepositoryData.kt @@ -0,0 +1,21 @@ +package com.lagradost.cloudstream3.ui.settings.extensions + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +/** Same binary shape as the Android app's RepositoryData, used by extensions that manage repositories */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RepositoryData( + val iconUrl: String?, + val name: String, + val url: String, + val description: String? = null, +) { + constructor(name: String, url: String) : this(iconUrl = null, name = name, url = url, description = null) + + constructor(iconUrl: String?, name: String, url: String) : this( + iconUrl = iconUrl, + name = name, + url = url, + description = null + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt new file mode 100644 index 00000000000..77f970d10ed --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt @@ -0,0 +1,80 @@ +@file:Suppress("unused") + +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.content.SharedPreferences +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral + +const val PREFERENCES_NAME = "rebuild_preference" + +/** + * Desktop copy of the Android app's DataStore. Extensions store their settings (tokens, toggles) + * through it, mostly via the inline `getKey`/`setKey` helpers that get compiled into the extension. + */ +object DataStore { + fun Context.getSharedPrefs(): SharedPreferences = getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun Context.getDefaultSharedPrefs(): SharedPreferences = getSharedPreferences("default", Context.MODE_PRIVATE) + + fun getFolderName(folder: String, path: String): String = "$folder/$path" + + fun Context.getKeys(folder: String): List { + val fixedFolder = folder.trimEnd('/') + "/" + return 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 = containsKey(getFolderName(folder, path)) + + fun Context.containsKey(path: String): Boolean = getSharedPrefs().contains(path) + + fun Context.removeKey(path: String) { + try { + getSharedPrefs().edit().remove(path).apply() + } catch (e: Exception) { + logError(e) + } + } + + fun Context.removeKeys(folder: String): Int { + val keys = getKeys("$folder/") + val editor = getSharedPrefs().edit() + keys.forEach { editor.remove(it) } + editor.apply() + return keys.size + } + + 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? = try { + getSharedPrefs().getString(path, null)?.let { parseJson(it, valueType.kotlin) } + } catch (_: Exception) { + null + } + + fun Context.setKey(folder: String, path: String, value: T) = setKey(getFolderName(folder, path), value) + + inline fun Context.getKey(path: String, defVal: T?): T? = try { + val json: String = getSharedPrefs().getString(path, null) ?: return defVal + parseJson(json) + } catch (_: Exception) { + null + } + + inline fun Context.getKey(path: String): T? = getKey(path, null) + + inline fun Context.getKey(folder: String, path: String): T? = getKey(getFolderName(folder, path), null) + + inline fun Context.getKey(folder: String, path: String, defVal: T?): T? = + getKey(getFolderName(folder, path), defVal) ?: defVal +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt new file mode 100644 index 00000000000..251a0c9aa2a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt @@ -0,0 +1,26 @@ +package com.lagradost.cloudstream3.utils + +/** Copy of the Android app's Event, used by extensions to listen for app events */ +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) { + // Copy so observers can unsubscribe themselves while being called + val current = synchronized(observers) { observers.toList() } + for (observer in current) observer(value) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 4ff13fe801c..8b9a4061083 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -1,62 +1,80 @@ package com.lagradost.cloudstream4 -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.lagradost.cloudstream4.compose.BlackButton +import androidx.compose.ui.window.rememberWindowState +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.disk.DiskCache +import coil3.network.ktor3.KtorNetworkFetcherFactory import com.lagradost.cloudstream4.compose.LocalFocusOutlineDefault -import com.lagradost.cloudstream4.compose.WhiteButton +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.ui.App +import com.lagradost.cloudstream4.desktop.ui.LocalWindowState +import com.lagradost.cloudstream4.desktop.ui.Navigator +import com.lagradost.cloudstream4.desktop.ui.Route 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.theme.CloudStreamTheme -import com.lagradost.cloudstream4.theme.CloudStreamThemeMode -import com.mihon.presentation.settings.widget.SwitchPreferenceWidget +import okio.Path.Companion.toOkioPath 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() { + System.setProperty("apple.awt.application.name", "CloudStream") + System.setProperty("apple.awt.application.appearance", "system") + + application { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { add(KtorNetworkFetcherFactory()) } + .diskCache { + DiskCache.Builder() + .directory(AppDirs.cacheDir.resolve("images").toOkioPath()) + .maxSizeBytes(256L * 1024 * 1024) + .build() } - } + .build() + } + val navigator = remember { Navigator() } + val windowState = rememberWindowState(width = 1280.dp, height = 820.dp) + val settings by AppData.settings.collectAsState() + + Window( + onCloseRequest = ::exitApplication, + title = stringResource(Res.string.app_name), + icon = painterResource(Res.drawable.default_icon), + state = windowState, + onPreviewKeyEvent = { event -> + // Escape/Alt+Left/Cmd+[ go back everywhere except the player, which handles its own keys + val isBack = event.type == KeyEventType.KeyDown && ( + event.key == Key.Escape || + (event.isAltPressed && event.key == Key.DirectionLeft) || + (event.isMetaPressed && event.key == Key.LeftBracket) + ) + isBack && navigator.current !is Route.Player && navigator.back() + }, + ) { + window.minimumSize = java.awt.Dimension(800, 560) + CloudStreamTheme(mode = settings.theme, primaryColor = settings.primaryColor) { + CompositionLocalProvider(LocalFocusOutlineDefault provides false, LocalWindowState provides windowState) { + App(navigator) + } + } } } -} \ No newline at end of file +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt new file mode 100644 index 00000000000..a4328797b9d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt @@ -0,0 +1,151 @@ +package com.lagradost.cloudstream4.desktop.data + +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor +import com.lagradost.cloudstream4.theme.CloudStreamThemeMode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.io.File + +data class RepositoryEntry( + val name: String, + val url: String, + val iconUrl: String? = null, +) + +data class InstalledPlugin( + val internalName: String, + val name: String, + val repositoryUrl: String, + val version: Int, + val filePath: String, + val iconUrl: String? = null, + /** If the extension was converted from an Android-only .cs3 file */ + val convertedFromDex: Boolean = false, + /** [com.lagradost.cloudstream4.desktop.plugins.DexConverter.VERSION] used for the conversion */ + val converterVersion: Int = 0, +) + +/** Something the user saved or watched, enough to open the result page again. */ +data class MediaEntry( + val name: String, + val url: String, + val apiName: String, + val posterUrl: String? = null, + val type: TvType? = null, + val posterHeaders: Map? = null, +) + +fun SearchResponse.toMediaEntry() = MediaEntry(name, url, apiName, posterUrl, type, posterHeaders) + +data class WatchProgress( + val media: MediaEntry, + val episodeName: String? = null, + val episodeData: String? = null, + val season: Int? = null, + val episode: Int? = null, + val positionMs: Long = 0, + val durationMs: Long = 0, + val updatedAt: Long = System.currentTimeMillis(), +) + +data class Position(val positionMs: Long, val durationMs: Long) { + val fraction: Float get() = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) +} + +enum class PlayerKind { Internal, External } + +data class Settings( + val theme: CloudStreamThemeMode = CloudStreamThemeMode.Dark, + val primaryColor: CloudStreamPrimaryColor = CloudStreamPrimaryColor.NORMAL, + val player: PlayerKind = PlayerKind.Internal, + /** Full path or command of the external player, empty means auto detect VLC/mpv */ + val externalPlayer: String = "", + /** Route raw.githubusercontent.com through jsDelivr, useful where GitHub is blocked */ + val jsdelivrProxy: Boolean = false, + /** The provider shown on the home page */ + val homeProvider: String? = null, + val seekSeconds: Int = 10, + /** Empty means ~/Downloads/CloudStream */ + val downloadDirectory: String = "", +) + +/** + * Persistent app state for the desktop app. Each collection is exposed as a [StateFlow] + * so the UI updates as soon as anything changes. + */ +object AppData { + private val store = JsonFileStore(File(AppDirs.dataDir, "data.json")) + + private val _settings = MutableStateFlow(store.get("settings", Settings())) + val settings: StateFlow = _settings.asStateFlow() + + private val _repositories = MutableStateFlow(store.get("repositories", emptyList())) + val repositories: StateFlow> = _repositories.asStateFlow() + + private val _plugins = MutableStateFlow(store.get("plugins", emptyList())) + val plugins: StateFlow> = _plugins.asStateFlow() + + private val _bookmarks = MutableStateFlow(store.get("bookmarks", emptyList())) + val bookmarks: StateFlow> = _bookmarks.asStateFlow() + + private val _history = MutableStateFlow(store.get("history", emptyList())) + val history: StateFlow> = _history.asStateFlow() + + private fun MutableStateFlow.persist(key: String, transform: (T) -> T) { + update(transform) + store.set(key, value) + } + + fun updateSettings(transform: (Settings) -> Settings) = _settings.persist("settings", transform) + + fun addRepository(repo: RepositoryEntry) = _repositories.persist("repositories") { list -> + val index = list.indexOfFirst { it.url == repo.url } + if (index == -1) list + repo else list.toMutableList().also { it[index] = repo } + } + + fun removeRepository(url: String) = _repositories.persist("repositories") { list -> + list.filterNot { it.url == url } + } + + fun putPlugin(plugin: InstalledPlugin) = _plugins.persist("plugins") { list -> + list.filterNot { it.filePath == plugin.filePath } + plugin + } + + fun removePlugin(filePath: String) = _plugins.persist("plugins") { list -> + list.filterNot { it.filePath == filePath } + } + + fun isBookmarked(url: String) = _bookmarks.value.any { it.url == url } + + fun toggleBookmark(entry: MediaEntry) = _bookmarks.persist("bookmarks") { list -> + if (list.any { it.url == entry.url }) list.filterNot { it.url == entry.url } else listOf(entry) + list + } + + /** Playback position per episode/movie data string, used to resume and show watched bars */ + private val _positions = MutableStateFlow(store.get("positions", emptyMap())) + val positions: StateFlow> = _positions.asStateFlow() + + /** Keeps one history entry per show, the most recently watched episode, newest first */ + fun saveProgress(progress: WatchProgress) { + _history.persist("history") { list -> + listOf(progress) + list.filterNot { it.media.url == progress.media.url }.take(200) + } + val key = progress.episodeData ?: return + _positions.persist("positions") { map -> + // LinkedHashMap keeps insertion order, so the oldest entries are dropped first + val next = LinkedHashMap(map) + next.remove(key) + next[key] = Position(progress.positionMs, progress.durationMs) + if (next.size > 5000) next.keys.take(next.size - 5000).forEach { next.remove(it) } + next + } + } + + fun removeHistory(url: String) = _history.persist("history") { list -> list.filterNot { it.media.url == url } } + + fun clearHistory() = _history.persist("history") { emptyList() } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppDirs.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppDirs.kt new file mode 100644 index 00000000000..0d2e4d064da --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppDirs.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream4.desktop.data + +import java.io.File + +enum class OperatingSystem { MacOS, Windows, Linux } + +object AppDirs { + val os: OperatingSystem = System.getProperty("os.name").lowercase().let { + when { + it.contains("mac") || it.contains("darwin") -> OperatingSystem.MacOS + it.contains("win") -> OperatingSystem.Windows + else -> OperatingSystem.Linux + } + } + + private val home = File(System.getProperty("user.home")) + + /** Where settings, repositories and extensions live, following each platform's conventions. */ + val dataDir: File by lazy { + when (os) { + OperatingSystem.MacOS -> File(home, "Library/Application Support/CloudStream") + OperatingSystem.Windows -> File(System.getenv("APPDATA") ?: home.path, "CloudStream") + OperatingSystem.Linux -> File( + System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } ?: "${home.path}/.local/share", + "cloudstream" + ) + }.also { it.mkdirs() } + } + + val cacheDir: File by lazy { + when (os) { + OperatingSystem.MacOS -> File(home, "Library/Caches/CloudStream") + OperatingSystem.Windows -> File(System.getenv("LOCALAPPDATA") ?: dataDir.path, "CloudStream/Cache") + OperatingSystem.Linux -> File( + System.getenv("XDG_CACHE_HOME")?.takeIf { it.isNotBlank() } ?: "${home.path}/.cache", + "cloudstream" + ) + }.also { it.mkdirs() } + } + + val extensionsDir: File get() = File(dataDir, "Extensions").also { it.mkdirs() } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonFileStore.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonFileStore.kt new file mode 100644 index 00000000000..5347398e393 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonFileStore.kt @@ -0,0 +1,71 @@ +package com.lagradost.cloudstream4.desktop.data + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.convertValue +import com.lagradost.cloudstream3.mapper +import com.lagradost.cloudstream3.mvvm.logError +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * A small persistent key-value store backed by a single JSON file. + * Every write is flushed to disk atomically, the data sets are small enough for this to be fine. + */ +class JsonFileStore(private val file: File) { + private val lock = Any() + private val root: ObjectNode = synchronized(lock) { + try { + if (file.exists()) mapper.readTree(file) as? ObjectNode else null + } catch (t: Throwable) { + logError(t) + // Keep the broken file around instead of silently discarding the user's data + file.copyTo(File(file.path + ".broken"), overwrite = true) + null + } ?: mapper.createObjectNode() + } + + fun keys(): List = synchronized(lock) { root.fieldNames().asSequence().toList() } + + fun node(key: String): JsonNode? = synchronized(lock) { root.get(key) } + + inline fun get(key: String): T? { + val value = node(key) ?: return null + return try { + mapper.convertValue(value) + } catch (t: Throwable) { + logError(t) + null + } + } + + inline fun get(key: String, default: T): T = get(key) ?: default + + fun set(key: String, value: Any?) { + synchronized(lock) { + if (value == null) root.remove(key) else root.set(key, mapper.valueToTree(value)) + save() + } + } + + fun remove(key: String) = set(key, null) + + fun clear() { + synchronized(lock) { + root.removeAll() + save() + } + } + + private fun save() { + try { + file.parentFile?.mkdirs() + val temp = File(file.path + ".tmp") + mapper.writerWithDefaultPrettyPrinter().writeValue(temp, root) + Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + } catch (t: Throwable) { + logError(t) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonSharedPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonSharedPreferences.kt new file mode 100644 index 00000000000..6d948c4a23a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/JsonSharedPreferences.kt @@ -0,0 +1,51 @@ +package com.lagradost.cloudstream4.desktop.data + +import android.content.SharedPreferences +import com.fasterxml.jackson.databind.JsonNode +import java.io.File + +/** [SharedPreferences] for extensions, persisted as JSON. */ +class JsonSharedPreferences(file: File) : SharedPreferences { + private val store = JsonFileStore(file) + + private fun raw(key: String): JsonNode? = store.node(key)?.takeUnless { it.isNull } + + override fun getAll(): Map = store.keys().associateWith { store.get(it) } + override fun getString(key: String, defValue: String?): String? = raw(key)?.asText() ?: defValue + override fun getStringSet(key: String, defValues: Set?): Set? = + store.get>(key) ?: defValues + + override fun getInt(key: String, defValue: Int): Int = raw(key)?.asInt(defValue) ?: defValue + override fun getLong(key: String, defValue: Long): Long = raw(key)?.asLong(defValue) ?: defValue + override fun getFloat(key: String, defValue: Float): Float = + raw(key)?.asDouble(defValue.toDouble())?.toFloat() ?: defValue + + override fun getBoolean(key: String, defValue: Boolean): Boolean = raw(key)?.asBoolean(defValue) ?: defValue + override fun contains(key: String): Boolean = raw(key) != null + + override fun edit(): SharedPreferences.Editor = Editor() + + private inner class Editor : SharedPreferences.Editor { + private val changes = LinkedHashMap() + private var clear = false + + override fun putString(key: String, value: String?) = apply { changes[key] = value } + override fun putStringSet(key: String, values: Set?) = apply { changes[key] = values } + override fun putInt(key: String, value: Int) = apply { changes[key] = value } + override fun putLong(key: String, value: Long) = apply { changes[key] = value } + override fun putFloat(key: String, value: Float) = apply { changes[key] = value } + override fun putBoolean(key: String, value: Boolean) = apply { changes[key] = value } + override fun remove(key: String) = apply { changes[key] = null } + override fun clear() = apply { clear = true } + + override fun commit(): Boolean { + if (clear) store.clear() + changes.forEach { (key, value) -> store.set(key, value) } + return true + } + + override fun apply() { + commit() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/downloads/DownloadManager.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/downloads/DownloadManager.kt new file mode 100644 index 00000000000..0b7558517ff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/downloads/DownloadManager.kt @@ -0,0 +1,311 @@ +package com.lagradost.cloudstream4.desktop.downloads + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.utils.DrmExtractorLink +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.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.JsonFileStore +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.data.MediaEntry +import com.lagradost.cloudstream4.desktop.ui.Playable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeout +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.coroutineContext + +enum class DownloadStatus { Queued, Downloading, Paused, Completed, Failed } + +data class DownloadItem( + /** The episode/movie data string, unique per downloadable item */ + val id: String, + val media: MediaEntry, + val title: String, + val season: Int? = null, + val episode: Int? = null, + val filePath: String, + val status: DownloadStatus = DownloadStatus.Queued, + val downloadedBytes: Long = 0, + val totalBytes: Long = 0, + /** Number of HLS segments written, used to resume */ + val segmentsDone: Int = 0, + val segmentsTotal: Int = 0, + val subtitlePaths: List = emptyList(), + val error: String? = null, + val createdAt: Long = System.currentTimeMillis(), +) { + val progress: Float + get() = when { + status == DownloadStatus.Completed -> 1f + segmentsTotal > 0 -> segmentsDone.toFloat() / segmentsTotal + totalBytes > 0 -> downloadedBytes.toFloat() / totalBytes + else -> 0f + } + + val file: File get() = File(filePath) +} + +/** + * Downloads movies and episodes for offline viewing. Plain video links are downloaded directly + * (resumable with Range requests) and HLS streams are fetched segment by segment into a .ts file. + */ +object DownloadManager { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val store = JsonFileStore(File(AppDirs.dataDir, "downloads.json")) + private val jobs = ConcurrentHashMap() + private val parallel = Semaphore(2) + + private val _downloads = MutableStateFlow( + store.get("items", emptyList()).map { + // Anything that was running when the app closed is resumable + if (it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued) it.copy(status = DownloadStatus.Paused) else it + } + ) + val downloads: StateFlow> = _downloads.asStateFlow() + + val defaultDirectory: File + get() = File(System.getProperty("user.home"), "Downloads/CloudStream") + + val directory: File + get() = AppData.settings.value.downloadDirectory.takeIf { it.isNotBlank() }?.let(::File) ?: defaultDirectory + + fun forEpisode(data: String): DownloadItem? = _downloads.value.firstOrNull { it.id == data } + + private fun persist() = store.set("items", _downloads.value) + + private fun update(id: String, persist: Boolean = false, transform: (DownloadItem) -> DownloadItem) { + _downloads.update { list -> list.map { if (it.id == id) transform(it) else it } } + if (persist) persist() + } + + private fun sanitize(name: String) = name.replace(Regex("[\\\\/:*?\"<>|\\n\\r\\t]"), "").trim().take(120).ifBlank { "download" } + + /** Queues a download, links are loaded from the extension unless [link] is given */ + fun enqueue(media: MediaEntry, playable: Playable, link: ExtractorLink? = null) { + val existing = forEpisode(playable.data) + if (existing != null) { + if (existing.status != DownloadStatus.Completed) resume(existing.id, link) + return + } + val folder = File(directory, sanitize(media.name)) + val item = DownloadItem( + id = playable.data, + media = media, + title = playable.title, + season = playable.season, + episode = playable.episode, + // The extension is picked once the link type is known + filePath = File(folder, sanitize(playable.title.removePrefix(media.name).removePrefix(" · ").ifBlank { media.name })).path, + ) + _downloads.update { listOf(item) + it } + persist() + start(item.id, link) + } + + fun resume(id: String, link: ExtractorLink? = null) { + if (jobs[id]?.isActive == true) return + update(id, persist = true) { it.copy(status = DownloadStatus.Queued, error = null) } + start(id, link) + } + + fun pause(id: String) { + jobs.remove(id)?.cancel() + update(id, persist = true) { it.copy(status = DownloadStatus.Paused) } + } + + fun delete(id: String) { + jobs.remove(id)?.cancel() + val item = forEpisode(id) ?: return + item.file.delete() + File(item.filePath + ".part").delete() + item.subtitlePaths.forEach { File(it).delete() } + item.file.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete() + _downloads.update { list -> list.filterNot { it.id == id } } + persist() + } + + private fun start(id: String, link: ExtractorLink?) { + jobs[id] = scope.launch { + parallel.withPermit { + try { + run(id, link) + } catch (t: Throwable) { + if (t is CancellationException) throw t + logError(t) + update(id, persist = true) { it.copy(status = DownloadStatus.Failed, error = t.message ?: t.javaClass.simpleName) } + } finally { + jobs.remove(id) + } + } + } + } + + private suspend fun loadLinks(item: DownloadItem): Pair, List> { + val api = APIHolder.getApiFromNameNull(item.media.apiName) + ?: throw IllegalStateException("The extension ${item.media.apiName} is not loaded") + val links = mutableSetOf() + val subtitles = mutableListOf() + try { + withTimeout(api.loadLinksTimeoutMs ?: 120_000L) { + api.loadLinks(item.id, false, { synchronized(subtitles) { subtitles += it } }) { link -> + if (link !is DrmExtractorLink && (link.type == ExtractorLinkType.VIDEO || link.type == ExtractorLinkType.M3U8)) { + synchronized(links) { links += link } + } + } + } + } catch (t: Throwable) { + if (t is CancellationException && t !is kotlinx.coroutines.TimeoutCancellationException) throw t + logError(t) + } + return sortUrls(links) to subtitles + } + + private suspend fun run(id: String, preferred: ExtractorLink?) { + update(id) { it.copy(status = DownloadStatus.Downloading) } + val item = forEpisode(id) ?: return + val (links, subtitles) = if (preferred != null) listOf(preferred) to emptyList() else loadLinks(item) + if (links.isEmpty()) throw IllegalStateException("No downloadable links found") + + var lastError: Throwable? = null + for (link in links) { + coroutineContext.ensureActive() + try { + val extension = if (link.type == ExtractorLinkType.M3U8) "ts" else + link.url.substringBefore('?').substringAfterLast('.', "mp4").lowercase().takeIf { it in setOf("mp4", "mkv", "webm", "avi", "mov") } ?: "mp4" + val base = item.filePath.substringBeforeLast(".${item.file.extension}").takeIf { item.file.extension in setOf("ts", "mp4", "mkv", "webm", "avi", "mov") } + ?: item.filePath + val target = File("$base.$extension") + update(id) { it.copy(filePath = target.path) } + target.parentFile.mkdirs() + + if (link.type == ExtractorLinkType.M3U8) downloadHls(id, link, target) else downloadFile(id, link, target) + val subtitleFiles = downloadSubtitles(target, subtitles) + update(id, persist = true) { + it.copy(status = DownloadStatus.Completed, subtitlePaths = subtitleFiles.map { f -> f.path }, error = null) + } + return + } catch (t: Throwable) { + if (t is CancellationException) throw t + logError(t) + lastError = t + // A different link means a different file, start over + update(id) { it.copy(downloadedBytes = 0, totalBytes = 0, segmentsDone = 0, segmentsTotal = 0) } + } + } + throw lastError ?: IllegalStateException("Download failed") + } + + private fun headersFor(link: ExtractorLink): Map = buildMap { + put("User-Agent", USER_AGENT) + if (link.referer.isNotBlank()) put("Referer", link.referer) + putAll(link.headers) + } + + private suspend fun downloadFile(id: String, link: ExtractorLink, target: File) { + val part = File(target.path + ".part") + val offset = if (part.exists()) part.length() else 0L + val request = Request.Builder().url(link.url).apply { + headersFor(link).forEach { (k, v) -> header(k, v) } + if (offset > 0) header("Range", "bytes=$offset-") + }.build() + + app.baseClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IllegalStateException("HTTP ${response.code}") + val resumed = response.code == 206 + val length = response.body.contentLength() + val total = if (length > 0) length + (if (resumed) offset else 0) else 0 + var written = if (resumed) offset else 0L + update(id) { it.copy(totalBytes = total, downloadedBytes = written) } + + FileOutputStream(part, resumed).use { out -> + response.body.byteStream().use { input -> + val buffer = ByteArray(256 * 1024) + var lastUpdate = 0L + while (true) { + coroutineContext.ensureActive() + val read = input.read(buffer) + if (read == -1) break + out.write(buffer, 0, read) + written += read + val now = System.currentTimeMillis() + if (now - lastUpdate > 300) { + lastUpdate = now + update(id) { it.copy(downloadedBytes = written) } + } + } + } + } + update(id) { it.copy(downloadedBytes = written, totalBytes = maxOf(total, written)) } + } + if (!part.renameTo(target)) { + part.copyTo(target, overwrite = true) + part.delete() + } + } + + private suspend fun downloadHls(id: String, link: ExtractorLink, target: File) { + val data = M3u8Helper2.hslLazy( + M3u8Helper.M3u8Stream(link.url, link.quality, headersFor(link)), + selectBest = true, + requireAudio = true, + ) + val part = File(target.path + ".part") + val start = forEpisode(id)?.segmentsDone?.takeIf { part.exists() && it < data.size } ?: 0 + update(id) { it.copy(segmentsTotal = data.size, segmentsDone = start) } + + var written = if (start > 0) part.length() else 0L + FileOutputStream(part, start > 0).use { out -> + for (index in start until data.size) { + coroutineContext.ensureActive() + val bytes = data.resolveLinkSafe(index) ?: throw IllegalStateException("Failed to download segment $index") + out.write(bytes) + out.flush() + written += bytes.size + update(id, persist = index % 25 == 0) { it.copy(segmentsDone = index + 1, downloadedBytes = written) } + } + } + if (!part.renameTo(target)) { + part.copyTo(target, overwrite = true) + part.delete() + } + } + + /** Saves subtitles next to the video with the same name, so VLC and other players pick them up */ + private suspend fun downloadSubtitles(video: File, subtitles: List): List = + subtitles.distinctBy { it.lang }.take(10).mapNotNull { subtitle -> + try { + val text = app.get(subtitle.url, headers = subtitle.headers.orEmpty()).text + val extension = when { + text.trimStart().startsWith("WEBVTT") -> "vtt" + text.contains("[Script Info]") -> "ass" + else -> "srt" + } + File(video.parentFile, "${video.nameWithoutExtension}.${sanitize(subtitle.lang)}.$extension").apply { writeText(text) } + } catch (t: Throwable) { + logError(t) + null + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/ExternalPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/ExternalPlayer.kt new file mode 100644 index 00000000000..85920b563d1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/ExternalPlayer.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream4.desktop.player + +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.OperatingSystem +import java.io.File + +/** Launches VLC, mpv or IINA (or the user's configured player) as a separate process. */ +object ExternalPlayer { + private fun onPath(name: String): String? { + val exe = if (AppDirs.os == OperatingSystem.Windows) "$name.exe" else name + return System.getenv("PATH").orEmpty().split(File.pathSeparator) + .map { File(it, exe) } + .firstOrNull { it.canExecute() }?.absolutePath + } + + private fun candidates(): List = when (AppDirs.os) { + OperatingSystem.MacOS -> listOf( + "/Applications/IINA.app/Contents/MacOS/iina-cli", + "/opt/homebrew/bin/mpv", + "/usr/local/bin/mpv", + "/Applications/VLC.app/Contents/MacOS/VLC", + ) + + OperatingSystem.Windows -> listOf( + "${System.getenv("ProgramFiles")}\\VideoLAN\\VLC\\vlc.exe", + "${System.getenv("ProgramFiles(x86)")}\\VideoLAN\\VLC\\vlc.exe", + ) + + OperatingSystem.Linux -> emptyList() + }.filter { File(it).exists() } + listOfNotNull(onPath("mpv"), onPath("vlc")) + + fun detect(): String? = AppData.settings.value.externalPlayer.takeIf { it.isNotBlank() } ?: candidates().firstOrNull() + + /** @return false if no player could be found */ + fun launch(url: String, title: String, subtitles: List): Boolean { + val player = detect() ?: return false + val name = File(player).name.lowercase() + val command = mutableListOf(player) + when { + name.startsWith("mpv") -> { + command += "--force-media-title=$title" + subtitles.forEach { command += "--sub-file=${it.absolutePath}" } + } + + name.startsWith("iina") -> { + command += "--mpv-force-media-title=$title" + subtitles.forEach { command += "--mpv-sub-file=${it.absolutePath}" } + } + + name.startsWith("vlc") -> { + command += "--meta-title=$title" + subtitles.firstOrNull()?.let { command += "--sub-file=${it.absolutePath}" } + } + } + command += url + ProcessBuilder(command).inheritIO().start() + return true + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/StreamProxy.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/StreamProxy.kt new file mode 100644 index 00000000000..e8fb6f270b9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/StreamProxy.kt @@ -0,0 +1,142 @@ +package com.lagradost.cloudstream4.desktop.player + +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.URLDecoder +import java.net.URLEncoder +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * A localhost-only HTTP proxy that attaches the headers an extension asks for (referer, cookies, origin...) + * to every request of a stream. VLC and external players cannot send arbitrary headers, so streams are + * played through this instead. HLS playlists are rewritten so segments and keys also go through the proxy. + */ +object StreamProxy { + private val sessions = ConcurrentHashMap>() + + private val client: OkHttpClient by lazy { + app.baseClient.newBuilder() + .followRedirects(true) + .readTimeout(60, TimeUnit.SECONDS) + .build() + } + + private val server: HttpServer by lazy { + HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0).apply { + createContext("/stream") { exchange -> + try { + handle(exchange) + } catch (t: Throwable) { + logError(t) + runCatching { exchange.sendResponseHeaders(502, -1) } + } finally { + exchange.close() + } + } + executor = Executors.newCachedThreadPool { runnable -> + Thread(runnable, "StreamProxy").apply { isDaemon = true } + } + start() + } + } + + private val port: Int get() = server.address.port + + /** + * @return a localhost url that serves [url] with [headers] applied. [referer] is folded into the headers. + */ + fun proxy(url: String, headers: Map, referer: String?): String { + val all = LinkedHashMap() + all["User-Agent"] = USER_AGENT + if (!referer.isNullOrBlank()) all["Referer"] = referer + // Extension headers take priority, regardless of the casing they used + headers.forEach { (key, value) -> + all.keys.firstOrNull { it.equals(key, ignoreCase = true) }?.let { all.remove(it) } + all[key] = value + } + val session = UUID.randomUUID().toString() + sessions[session] = all + return urlFor(session, url) + } + + private fun urlFor(session: String, target: String): String = + "http://127.0.0.1:$port/stream?s=$session&u=${URLEncoder.encode(target, Charsets.UTF_8)}" + + private fun query(exchange: HttpExchange): Map = + exchange.requestURI.rawQuery.orEmpty().split('&').mapNotNull { part -> + val index = part.indexOf('=') + if (index == -1) null + else part.substring(0, index) to URLDecoder.decode(part.substring(index + 1), Charsets.UTF_8) + }.toMap() + + private fun isPlaylist(url: String, contentType: String?): Boolean { + val type = contentType?.lowercase().orEmpty() + return type.contains("mpegurl") || url.substringBefore('?').lowercase().endsWith(".m3u8") + } + + private fun handle(exchange: HttpExchange) { + val params = query(exchange) + val session = params["s"] ?: return exchange.sendResponseHeaders(400, -1) + val target = params["u"] ?: return exchange.sendResponseHeaders(400, -1) + val headers = sessions[session] ?: return exchange.sendResponseHeaders(404, -1) + + val request = Request.Builder().url(target).apply { + headers.forEach { (key, value) -> header(key, value) } + exchange.requestHeaders.getFirst("Range")?.let { header("Range", it) } + }.build() + + client.newCall(request).execute().use { response -> + val body = response.body + val contentType = response.header("Content-Type") + val finalUrl = response.request.url.toString() + + // Some hosts serve playlists as octet-stream, so also sniff the first bytes + val peek = runCatching { response.peekBody(7).string() }.getOrDefault("") + if (isPlaylist(finalUrl, contentType) || peek.startsWith("#EXTM3U")) { + val rewritten = rewritePlaylist(session, finalUrl, body.string()).toByteArray() + exchange.responseHeaders.add("Content-Type", "application/vnd.apple.mpegurl") + exchange.sendResponseHeaders(response.code, rewritten.size.toLong()) + exchange.responseBody.use { it.write(rewritten) } + return + } + + listOf("Content-Type", "Content-Range", "Accept-Ranges", "Content-Disposition").forEach { name -> + response.header(name)?.let { exchange.responseHeaders.add(name, it) } + } + val length = body.contentLength() + exchange.sendResponseHeaders(response.code, if (length > 0) length else 0) + exchange.responseBody.use { out -> + body.byteStream().use { it.copyTo(out, 64 * 1024) } + } + } + } + + private val uriAttribute = Regex("URI=\"([^\"]+)\"") + + private fun rewritePlaylist(session: String, baseUrl: String, playlist: String): String { + val base = baseUrl.toHttpUrlOrNull() ?: return playlist + fun absolute(uri: String) = base.resolve(uri.trim())?.toString() ?: uri + + return playlist.lineSequence().joinToString("\n") { line -> + when { + line.isBlank() -> line + line.startsWith("#") -> uriAttribute.replace(line) { match -> + "URI=\"${urlFor(session, absolute(match.groupValues[1]))}\"" + } + + else -> urlFor(session, absolute(line)) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt new file mode 100644 index 00000000000..1e49bb33a14 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt @@ -0,0 +1,264 @@ +package com.lagradost.cloudstream4.desktop.player + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import com.lagradost.cloudstream3.mvvm.logError +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.factory.MediaPlayerFactory +import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter +import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.io.File +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.SwingUtilities + +data class Track(val id: Int, val name: String) + +object Vlc { + /** Null if LibVLC (the VLC app) is not installed */ + val factory: MediaPlayerFactory? by lazy { + try { + if (NativeDiscovery().discover()) { + MediaPlayerFactory("--no-video-title-show", "--quiet", "--network-caching=3000") + } else null + } catch (t: Throwable) { + logError(t) + null + } + } + + val isAvailable: Boolean get() = factory != null +} + +/** + * Wraps a LibVLC media player that renders frames into a Compose [ImageBitmap], so the video + * can be drawn with regular Compose UI layered on top, on every desktop platform. + */ +class VlcEngine(factory: MediaPlayerFactory) { + var frame by mutableStateOf(null) + private set + var isPlaying by mutableStateOf(false) + private set + var isBuffering by mutableStateOf(true) + private set + var timeMs by mutableStateOf(0L) + private set + var lengthMs by mutableStateOf(0L) + private set + var volume by mutableStateOf(100) + private set + var rate by mutableStateOf(1f) + private set + var audioTracks by mutableStateOf(emptyList()) + private set + var audioTrack by mutableStateOf(-1) + private set + var subtitleTracks by mutableStateOf(emptyList()) + private set + var subtitleTrack by mutableStateOf(-1) + private set + var hasError by mutableStateOf(false) + private set + var hasFinished by mutableStateOf(false) + private set + + /** Only touched on the UI thread */ + private var bitmap = Bitmap() + private var bitmapInfo: ImageInfo? = null + + /** Written by the VLC threads */ + @Volatile + private var imageInfo: ImageInfo? = null + @Volatile + private var frameBytes = ByteArray(0) + private val framePending = AtomicBoolean(false) + private var released = false + + private val player: EmbeddedMediaPlayer = factory.mediaPlayers().newEmbeddedMediaPlayer() + + init { + val formatCallback = object : BufferFormatCallback { + override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { + // Never block on the UI thread here, release() on the UI thread waits for VLC threads + frameBytes = ByteArray(sourceWidth * sourceHeight * 4) + imageInfo = ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.OPAQUE) + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun newFormatSize(bufferWidth: Int, bufferHeight: Int, displayWidth: Int, displayHeight: Int) = Unit + override fun allocatedBuffers(buffers: Array) = Unit + } + + val renderCallback = object : RenderCallback { + override fun lock(mediaPlayer: MediaPlayer) = Unit + override fun unlock(mediaPlayer: MediaPlayer) = Unit + + override fun display( + mediaPlayer: MediaPlayer, + nativeBuffers: Array, + bufferFormat: BufferFormat, + displayWidth: Int, + displayHeight: Int, + ) { + // Drop frames instead of queueing them if the UI thread falls behind + if (!framePending.compareAndSet(false, true)) return + val buffer = nativeBuffers[0] + buffer.rewind() + if (buffer.remaining() < frameBytes.size) { + framePending.set(false) + return + } + val bytes = frameBytes + val info = imageInfo + buffer.get(bytes) + SwingUtilities.invokeLater { + try { + if (info != null && !released) { + if (bitmapInfo != info) { + bitmap.close() + bitmap = Bitmap().apply { allocPixels(info) } + bitmapInfo = info + } + bitmap.installPixels(bytes) + frame = bitmap.asComposeImageBitmap() + } + } finally { + framePending.set(false) + } + } + } + } + + player.videoSurface().set(factory.videoSurfaces().newVideoSurface(formatCallback, renderCallback, true)) + player.events().addMediaPlayerEventListener(object : MediaPlayerEventAdapter() { + override fun playing(mediaPlayer: MediaPlayer) = ui { + isPlaying = true + isBuffering = false + } + + override fun paused(mediaPlayer: MediaPlayer) = ui { isPlaying = false } + override fun stopped(mediaPlayer: MediaPlayer) = ui { isPlaying = false } + override fun finished(mediaPlayer: MediaPlayer) = ui { + isPlaying = false + hasFinished = true + } + + override fun error(mediaPlayer: MediaPlayer) = ui { hasError = true } + override fun buffering(mediaPlayer: MediaPlayer, newCache: Float) = ui { isBuffering = newCache < 100f } + override fun timeChanged(mediaPlayer: MediaPlayer, newTime: Long) = ui { timeMs = newTime } + override fun lengthChanged(mediaPlayer: MediaPlayer, newLength: Long) = ui { lengthMs = newLength } + override fun elementaryStreamAdded( + mediaPlayer: MediaPlayer, + type: uk.co.caprica.vlcj.media.TrackType, + id: Int, + ) = refreshTracks() + + override fun elementaryStreamSelected( + mediaPlayer: MediaPlayer, + type: uk.co.caprica.vlcj.media.TrackType, + id: Int, + ) = refreshTracks() + }) + } + + private fun ui(block: () -> Unit) = SwingUtilities.invokeLater(block) + + private fun refreshTracks() { + // Track queries are not allowed from the native callback thread + player.submit { + val audio = player.audio().trackDescriptions().map { Track(it.id(), it.description()) } + val selectedAudio = player.audio().track() + val subs = player.subpictures().trackDescriptions().map { Track(it.id(), it.description()) } + val selectedSub = player.subpictures().track() + ui { + audioTracks = audio + audioTrack = selectedAudio + subtitleTracks = subs + subtitleTrack = selectedSub + } + } + } + + fun play(url: String, startMs: Long = 0) { + hasError = false + hasFinished = false + isBuffering = true + timeMs = startMs + lengthMs = 0 + val options = buildList { + if (startMs > 0) add(":start-time=${startMs / 1000.0}") + }.toTypedArray() + player.media().play(url, *options) + player.audio().setVolume(volume) + } + + fun togglePause() { + if (hasFinished) { + hasFinished = false + player.controls().setTime(0) + player.controls().play() + } else { + player.controls().pause() + } + } + + fun setPaused(paused: Boolean) = player.controls().setPause(paused) + + fun seekTo(ms: Long) { + val clamped = if (lengthMs > 0) ms.coerceIn(0, lengthMs) else ms.coerceAtLeast(0) + timeMs = clamped + player.controls().setTime(clamped) + } + + fun skip(ms: Long) = seekTo(timeMs + ms) + + fun changeVolume(value: Int) { + volume = value.coerceIn(0, 200) + player.audio().setVolume(volume) + } + + fun changeRate(value: Float) { + rate = value + player.controls().setRate(value) + } + + fun selectAudio(id: Int) { + player.audio().setTrack(id) + refreshTracks() + } + + fun selectSubtitle(id: Int) { + player.subpictures().setTrack(id) + refreshTracks() + } + + fun addSubtitleFile(file: File) { + player.subpictures().setSubTitleFile(file) + refreshTracks() + } + + fun stop() = player.controls().stop() + + fun release() { + if (released) return + released = true + frame = null + // Releasing blocks until LibVLC is done, keep that off the UI thread + Thread { + player.controls().stop() + player.release() + SwingUtilities.invokeLater { bitmap.close() } + }.start() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt new file mode 100644 index 00000000000..e2a78abccb8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt @@ -0,0 +1,130 @@ +package com.lagradost.cloudstream4.desktop.plugins + +import com.googlecode.d2j.dex.Dex2jar +import com.lagradost.cloudstream4.desktop.data.AppDirs +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +/** + * Turns an Android extension (.cs3, a zip with classes.dex and manifest.json) into a JVM jar. + */ +object DexConverter { + /** Bump when the conversion changes, older conversions are then repaired on startup */ + const val VERSION = 1 + + /** Applies the bytecode fixes of the current [VERSION] to a jar converted by an older version */ + fun repairJar(jar: File) { + val temp = File.createTempFile("extension", ".jar", AppDirs.cacheDir) + try { + ZipOutputStream(temp.outputStream()).use { out -> + ZipFile(jar).use { zip -> + for (entry in zip.entries()) { + if (entry.isDirectory) continue + val bytes = zip.getInputStream(entry).use { it.readBytes() } + out.putNextEntry(ZipEntry(entry.name)) + out.write(if (entry.name.endsWith(".class")) fixLocalClasses(bytes) else bytes) + out.closeEntry() + } + } + } + Files.move(temp.toPath(), jar.toPath(), StandardCopyOption.REPLACE_EXISTING) + } finally { + temp.delete() + } + } + + /** + * dex2jar turns local classes (classes declared inside a function) into member classes of the + * outer class and drops their EnclosingMethod attribute. The JVM is fine with that, but Kotlin + * reflection then looks for `Outer.Name`, which does not exist, so Jackson fails to parse into them. + * A member class is always named `Outer$Name`, anything else is turned back into a local class. + */ + private fun fixLocalClasses(bytes: ByteArray): ByteArray { + val reader = ClassReader(bytes) + val writer = ClassWriter(0) + var changed = false + reader.accept(object : ClassVisitor(Opcodes.ASM9, writer) { + private var className = "" + private var hasOuterClass = false + private var localOuter: String? = null + + override fun visit( + version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array?, + ) { + className = name + super.visit(version, access, name, signature, superName, interfaces) + } + + override fun visitOuterClass(owner: String?, name: String?, descriptor: String?) { + hasOuterClass = true + super.visitOuterClass(owner, name, descriptor) + } + + override fun visitInnerClass(name: String, outerName: String?, innerName: String?, access: Int) { + if (outerName != null && innerName != null && name != "$outerName$$innerName") { + changed = true + if (name == className) localOuter = outerName + super.visitInnerClass(name, null, innerName, access) + } else { + super.visitInnerClass(name, outerName, innerName, access) + } + } + + override fun visitEnd() { + // EnclosingMethod must come before InnerClasses in ASM's visit order, but ClassWriter + // does not care about the order of attributes, so adding it here is fine + localOuter?.takeIf { !hasOuterClass }?.let { super.visitOuterClass(it, null, null) } + super.visitEnd() + } + }, 0) + return if (changed) writer.toByteArray() else bytes + } + + fun cs3ToJar(cs3: File): File { + val classesDir = Files.createTempDirectory(AppDirs.cacheDir.toPath(), "dex2jar").toFile() + val jar = File.createTempFile("extension", ".jar", AppDirs.cacheDir) + try { + ZipOutputStream(jar.outputStream()).use { out -> + ZipFile(cs3).use { zip -> + for (entry in zip.entries()) { + if (entry.isDirectory) continue + val bytes = zip.getInputStream(entry).use { it.readBytes() } + if (entry.name.matches(Regex("classes\\d*\\.dex"))) { + Dex2jar.from(bytes) + .reUseReg(false) + .topoLogicalSort() + .skipDebug(false) + .optimizeSynchronized(true) + .to(classesDir.toPath()) + } else if (entry.name == "manifest.json" || !entry.name.startsWith("res/")) { + // Keep the manifest and any plain java resources, Android resources are useless here + out.putNextEntry(ZipEntry(entry.name)) + out.write(bytes) + out.closeEntry() + } + } + } + + classesDir.walkTopDown().filter { it.isFile && it.name.endsWith(".class") }.forEach { file -> + out.putNextEntry(ZipEntry(file.relativeTo(classesDir).invariantSeparatorsPath)) + out.write(fixLocalClasses(file.readBytes())) + out.closeEntry() + } + } + return jar + } catch (t: Throwable) { + jar.delete() + throw t + } finally { + classesDir.deleteRecursively() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/ExtensionClassLoader.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/ExtensionClassLoader.kt new file mode 100644 index 00000000000..d5f56c3a182 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/ExtensionClassLoader.kt @@ -0,0 +1,84 @@ +package com.lagradost.cloudstream4.desktop.plugins + +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 java.io.File +import java.net.URLClassLoader +import java.util.zip.ZipFile + +/** + * Loads one extension jar. Extensions built for Android reference Android and app classes that do + * not exist on desktop, mostly in settings screens. Instead of failing with NoClassDefFoundError as + * soon as such a class is touched, an empty placeholder class is generated for it. Calling into a + * placeholder still fails, but only in the code path that really needs Android. + */ +class ExtensionClassLoader(private val jar: File, parent: ClassLoader) : + URLClassLoader(arrayOf(jar.toURI().toURL()), parent) { + + private val stubbedPackages = listOf("android.", "androidx.", "com.google.android.", "com.lagradost.cloudstream3.") + + /** Classes the extension uses as interfaces, a placeholder must then be an interface too */ + private val interfaces: Set by lazy { findInterfaces() } + + private val _stubs = mutableListOf() + val stubs: List get() = synchronized(_stubs) { _stubs.toList() } + + override fun findClass(name: String): Class<*> { + try { + return super.findClass(name) + } catch (e: ClassNotFoundException) { + if (stubbedPackages.none { name.startsWith(it) }) throw e + val bytes = generateStub(name.replace('.', '/')) + synchronized(_stubs) { _stubs += name } + return defineClass(name, bytes, 0, bytes.size) + } + } + + private fun generateStub(internalName: String): ByteArray { + val writer = ClassWriter(0) + if (internalName in interfaces) { + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC or Opcodes.ACC_ABSTRACT or Opcodes.ACC_INTERFACE, internalName, null, "java/lang/Object", null) + } else { + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null) + writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null).apply { + visitCode() + visitVarInsn(Opcodes.ALOAD, 0) + visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false) + visitInsn(Opcodes.RETURN) + visitMaxs(1, 1) + visitEnd() + } + } + writer.visitEnd() + return writer.toByteArray() + } + + private fun findInterfaces(): Set { + val result = HashSet() + ZipFile(jar).use { zip -> + for (entry in zip.entries()) { + if (!entry.name.endsWith(".class")) continue + val reader = ClassReader(zip.getInputStream(entry).use { it.readBytes() }) + reader.accept(object : ClassVisitor(Opcodes.ASM9) { + override fun visit( + version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array?, + ) { + interfaces?.let { result.addAll(it) } + } + + override fun visitMethod( + access: Int, name: String?, descriptor: String?, signature: String?, exceptions: Array?, + ): MethodVisitor = object : MethodVisitor(Opcodes.ASM9) { + override fun visitMethodInsn(opcode: Int, owner: String, name: String?, descriptor: String?, isInterface: Boolean) { + if (isInterface) result += owner + } + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + } + return result + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt new file mode 100644 index 00000000000..3db17fffb5c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt @@ -0,0 +1,377 @@ +package com.lagradost.cloudstream4.desktop.plugins + +import android.content.DesktopContext +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mapper +import com.lagradost.cloudstream3.mvvm.logError +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.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.data.InstalledPlugin +import com.lagradost.cloudstream4.desktop.data.RepositoryEntry +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 java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Repository( + val name: String, + val iconUrl: String? = null, + val description: String? = null, + val manifestVersion: Int = 1, + val pluginLists: List = emptyList(), +) + +/** An extension as listed in a repository's plugins.json */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RemotePlugin( + /** Url to the Android .cs3 file */ + val url: String, + /** 0: Down, 1: Ok, 2: Slow, 3: Beta only */ + val status: Int = 1, + val version: Int = 0, + val apiVersion: Int = 1, + val name: String, + val internalName: String, + val authors: List = emptyList(), + val description: String? = null, + val repositoryUrl: String? = null, + val tvTypes: List? = null, + val language: String? = null, + val iconUrl: String? = null, + val fileSize: Long? = null, + val fileHash: String? = null, + /** Url to a JVM build of the extension, published by newer versions of the gradle plugin */ + val jarUrl: String? = null, + val jarFileSize: Long? = null, + val jarHash: String? = null, +) { + fun icon(size: Int = 64): String? = iconUrl?.replace("%size%", "$size") +} + +data class LoadError(val name: String, val message: String) + +/** + * Desktop counterpart of the Android PluginManager and RepositoryManager. + * + * Extensions are stored as JVM jars. If a repository only provides Android .cs3 files the + * contained dex is converted to JVM bytecode when installing. + */ +object PluginManager { + private class LoadedPlugin(val instance: BasePlugin, val loader: ExtensionClassLoader) + + private val loaded = HashMap() + private val pluginLock = Mutex() + + /** Bumped whenever providers are added or removed so the UI can refresh */ + private val _providersVersion = MutableStateFlow(0) + val providersVersion: StateFlow = _providersVersion.asStateFlow() + + private val _errors = MutableStateFlow>(emptyList()) + val errors: StateFlow> = _errors.asStateFlow() + + private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + fun providers(): List = APIHolder.allProviders.withLock { APIHolder.allProviders.toList() } + + private fun convertRawGitUrl(url: String): String { + if (!AppData.settings.value.jsdelivrProxy) return url + val match = GH_REGEX.find(url) ?: return url + val (user, repo, rest) = match.destructured + return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest" + } + + /** Resolves full urls, cloudstreamrepo:// links and shortcodes to a repository url */ + suspend fun parseRepoUrl(input: String): String? { + val fixed = input.trim() + val repoScheme = Regex("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)") + return when { + fixed.contains(repoScheme) -> fixed.replace(repoScheme, "").let { + if (it.contains("^https?://".toRegex())) it else "https://$it" + } + + fixed.contains("^https?://".toRegex()) -> fixed + fixed.matches("^[a-zA-Z0-9!_-]+$".toRegex()) -> { + val (host, code) = if (fixed.startsWith("!")) "py.md" to fixed.removePrefix("!") else "cutt.ly" to fixed + try { + val response = app.get("https://$host/$code", allowRedirects = false) + response.headers["Location"]?.takeUnless { + it.startsWith("https://$host/404") || it.removeSuffix("/") == "https://$host" + } + } catch (t: Throwable) { + logError(t) + null + } + } + + else -> null + } + } + + suspend fun fetchRepository(url: String): Repository? = withContext(Dispatchers.IO) { + try { + val text = app.get(convertRawGitUrl(url), cacheTime = 15, cacheUnit = TimeUnit.MINUTES).text + mapper.readValue(text) + } catch (t: Throwable) { + logError(t) + null + } + } + + suspend fun fetchRepositoryPlugins(repository: Repository): List = coroutineScope { + repository.pluginLists.map { listUrl -> + async(Dispatchers.IO) { + try { + val text = app.get(convertRawGitUrl(listUrl), cacheTime = 15, cacheUnit = TimeUnit.MINUTES).text + mapper.readValue>(text) + } catch (t: Throwable) { + logError(t) + emptyList() + } + } + }.awaitAll().flatten() + // Lists of one repository can overlap, the UI keys extensions by internal name + .distinctBy { it.internalName } + } + + /** @return the added repository or null if the url is not a valid repository */ + suspend fun addRepository(input: String): RepositoryEntry? { + val url = parseRepoUrl(input) ?: return null + val repo = fetchRepository(url) ?: return null + return RepositoryEntry(repo.name, url, repo.iconUrl).also { AppData.addRepository(it) } + } + + suspend fun removeRepository(url: String) { + AppData.plugins.value.filter { it.repositoryUrl == url }.forEach { uninstall(it) } + File(AppDirs.extensionsDir, sanitizedFileName(url)).deleteRecursively() + AppData.removeRepository(url) + } + + private fun sanitizedFileName(name: String): String = + name.replace(Regex("[^A-Za-z0-9._-]"), "").take(100) + "." + name.hashCode() + + fun pluginFile(repositoryUrl: String, internalName: String): File = + File(AppDirs.extensionsDir, "${sanitizedFileName(repositoryUrl)}/${sanitizedFileName(internalName)}.jar") + + fun installedFor(repositoryUrl: String, internalName: String): InstalledPlugin? { + val path = pluginFile(repositoryUrl, internalName).absolutePath + return AppData.plugins.value.firstOrNull { it.filePath == path } + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(8192) + while (true) { + val read = input.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } + + private suspend fun download(url: String, expectedHash: String?): File = withContext(Dispatchers.IO) { + val temp = File.createTempFile("extension", ".tmp", AppDirs.cacheDir) + app.get(convertRawGitUrl(url)).okhttpResponse.body.byteStream().use { body -> + temp.outputStream().use { body.copyTo(it) } + } + if (expectedHash != null) { + val hash = sha256(temp) + if (hash != expectedHash) { + temp.delete() + throw IllegalStateException("Extension hash mismatch, expected $expectedHash got $hash") + } + } + temp + } + + /** Downloads (or updates) and loads an extension */ + suspend fun install(repositoryUrl: String, plugin: RemotePlugin): Result = runCatching { + val target = pluginFile(repositoryUrl, plugin.internalName) + val converted: Boolean + val jar = if (plugin.jarUrl != null) { + converted = false + download(plugin.jarUrl, plugin.jarHash) + } else { + converted = true + val cs3 = download(plugin.url, plugin.fileHash) + try { + withContext(Dispatchers.IO) { DexConverter.cs3ToJar(cs3) } + } finally { + cs3.delete() + } + } + + // Unload the old version before replacing the file, the class loader keeps it open + AppData.plugins.value.firstOrNull { it.filePath == target.absolutePath }?.let { unload(it.filePath) } + withContext(Dispatchers.IO) { + target.parentFile.mkdirs() + Files.move(jar.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + val installed = InstalledPlugin( + internalName = plugin.internalName, + name = plugin.name, + repositoryUrl = repositoryUrl, + version = plugin.version, + filePath = target.absolutePath, + iconUrl = plugin.icon(), + convertedFromDex = converted, + converterVersion = if (converted) DexConverter.VERSION else 0, + ) + AppData.putPlugin(installed) + load(installed).getOrThrow() + installed + } + + suspend fun uninstall(plugin: InstalledPlugin) { + unload(plugin.filePath) + AppData.removePlugin(plugin.filePath) + withContext(Dispatchers.IO) { File(plugin.filePath).delete() } + } + + /** Loads every installed extension, used on startup */ + suspend fun loadAll() { + loadAllInstalled() + // Some extensions finish setting up once every extension is loaded + runCatching { MainActivity.afterPluginsLoadedEvent.invoke(true) }.onFailure { logError(it) } + } + + private suspend fun loadAllInstalled() = withContext(Dispatchers.IO) { + AppData.plugins.value.forEach { plugin -> + if (!File(plugin.filePath).exists()) { + AppData.removePlugin(plugin.filePath) + } else if (plugin.convertedFromDex && plugin.converterVersion < DexConverter.VERSION) { + val repaired = runCatching { DexConverter.repairJar(File(plugin.filePath)) } + .onFailure { logError(it) }.isSuccess + val updated = if (repaired) plugin.copy(converterVersion = DexConverter.VERSION) else plugin + if (repaired) AppData.putPlugin(updated) + load(updated) + } else { + load(plugin) + } + } + } + + /** Checks every repository for newer versions of the installed extensions and installs them */ + suspend fun updateAll(): Int { + var updated = 0 + AppData.repositories.value.forEach { repoEntry -> + val repository = fetchRepository(repoEntry.url) ?: return@forEach + fetchRepositoryPlugins(repository).forEach { remote -> + val installed = installedFor(repoEntry.url, remote.internalName) ?: return@forEach + if (remote.version > installed.version && remote.status != 0) { + if (install(repoEntry.url, remote).isSuccess) updated++ + } + } + } + return updated + } + + private fun findPluginClassName(file: File): String? = ZipFile(file).use { zip -> + // Converted and Android extensions have a manifest + zip.getEntry("manifest.json")?.let { entry -> + val manifest = mapper.readValue(zip.getInputStream(entry)) + return manifest.pluginClassName + } + null + } + + private fun scanForPluginClass(file: File, loader: ClassLoader): Class<*>? = ZipFile(file).use { zip -> + zip.entries().asSequence() + .map { it.name } + .filter { it.endsWith(".class") && !it.contains('$') } + .map { it.removeSuffix(".class").replace('/', '.') } + .firstNotNullOfOrNull { name -> + try { + val clazz = Class.forName(name, false, loader) + clazz.takeIf { + it.isAnnotationPresent(CloudstreamPlugin::class.java) && + BasePlugin::class.java.isAssignableFrom(it) + } + } catch (_: Throwable) { + null + } + } + } + + /** Extensions may do blocking work such as network requests in load(), so this never runs on the UI thread */ + suspend fun load(plugin: InstalledPlugin): Result = withContext(Dispatchers.IO) { loadLocked(plugin) } + + private suspend fun loadLocked(plugin: InstalledPlugin): Result = pluginLock.withLock { + val file = File(plugin.filePath) + if (loaded.containsKey(file.absolutePath)) return@withLock Result.success(Unit) + _errors.update { list -> list.filterNot { it.name == plugin.name } } + + runCatching { + val loader = ExtensionClassLoader(file, PluginManager::class.java.classLoader) + try { + val pluginClass = findPluginClassName(file)?.let { loader.loadClass(it) } + ?: scanForPluginClass(file, loader) + ?: throw IllegalStateException("No @CloudstreamPlugin class found") + val instance = pluginClass.getDeclaredConstructor().newInstance() as BasePlugin + instance.filename = file.absolutePath + loaded[file.absolutePath] = LoadedPlugin(instance, loader) + if (instance is Plugin) instance.load(DesktopContext) else instance.load() + } catch (t: Throwable) { + loaded.remove(file.absolutePath) + removeRegistered(file.absolutePath) + loader.close() + throw t + } + _providersVersion.update { it + 1 } + Unit + }.onFailure { t -> + logError(t) + val message = when (t) { + // NoClassDefFoundError, NoSuchMethodError, NoSuchFieldError... + is LinkageError -> + "Uses Android-only features that are not available on desktop (${t.message})" + + else -> t.message ?: t.javaClass.simpleName + } + _errors.update { it + LoadError(plugin.name, message) } + } + } + + private fun removeRegistered(filename: String) { + APIHolder.apis.filter { it.sourcePlugin == filename }.forEach { APIHolder.removePluginMapping(it) } + APIHolder.allProviders.withLock { APIHolder.allProviders.removeAll { it.sourcePlugin == filename } } + extractorApis.withLock { extractorApis.removeAll { it.sourcePlugin == filename } } + } + + suspend fun unload(filePath: String) = pluginLock.withLock { + val plugin = loaded.remove(filePath) ?: return@withLock + try { + plugin.instance.beforeUnload() + } catch (t: Throwable) { + logError(t) + } + removeRegistered(filePath) + plugin.loader.close() + _providersVersion.update { it + 1 } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt new file mode 100644 index 00000000000..64666bc10b9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt @@ -0,0 +1,126 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.layout.Box +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.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.download +import com.lagradost.cloudstream4.desktop.generated.resources.extension +import com.lagradost.cloudstream4.desktop.generated.resources.home +import com.lagradost.cloudstream4.desktop.generated.resources.library +import com.lagradost.cloudstream4.desktop.generated.resources.search +import com.lagradost.cloudstream4.desktop.generated.resources.settings +import com.lagradost.cloudstream4.desktop.plugins.PluginManager +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource + +private val Tab.icon: DrawableResource + get() = when (this) { + Tab.Home -> Res.drawable.home + Tab.Search -> Res.drawable.search + Tab.Library -> Res.drawable.library + Tab.Downloads -> Res.drawable.download + Tab.Extensions -> Res.drawable.extension + Tab.Settings -> Res.drawable.settings + } + +@Composable +fun App(navigator: Navigator) { + val scope = rememberCoroutineScope() + val snackbar = remember { SnackbarHostState() } + val notify: (String) -> Unit = { message -> scope.launch { snackbar.showSnackbar(message) } } + + // Screen state that should survive switching tabs + val home = remember { HomeViewModel(scope) } + val search = remember { SearchViewModel(scope) } + val extensions = remember { ExtensionsViewModel(scope, notify) } + + // Extensions show toasts through CommonActivity + LaunchedEffect(Unit) { CommonActivity.toasts.collect { notify(it) } } + + var loadingPlugins by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + PluginManager.loadAll() + loadingPlugins = false + val failed = PluginManager.errors.value.size + if (failed > 0) notify("$failed extensions failed to load, see the Extensions tab") + } + + val stateHolder = rememberSaveableStateHolder() + val route = navigator.current + + CompositionLocalProvider(LocalNavigator provides navigator, LocalSnackbar provides snackbar) { + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + if (route is Route.Player) { + // The player takes over the whole window + PlayerScreen(route) + return@Scaffold + } + Row(Modifier.fillMaxSize()) { + NavigationRail(containerColor = MaterialTheme.colorScheme.surface) { + Tab.entries.forEach { tab -> + NavigationRailItem( + selected = navigator.tab == tab && route == null, + onClick = { navigator.select(tab) }, + icon = { Icon(painterResource(tab.icon), tab.title) }, + label = { Text(tab.title) }, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } + VerticalDivider() + Box(Modifier.weight(1f).fillMaxHeight()) { + when (route) { + is Route.Result -> stateHolder.SaveableStateProvider(route.toString()) { ResultScreen(route.media) } + null -> stateHolder.SaveableStateProvider(navigator.tab.name) { + when (navigator.tab) { + Tab.Home -> HomeScreen(home) + Tab.Search -> SearchScreen(search) + Tab.Library -> LibraryScreen() + Tab.Downloads -> DownloadsScreen() + Tab.Extensions -> ExtensionsScreen(extensions) + Tab.Settings -> SettingsScreen() + } + } + + is Route.Player -> Unit + } + if (loadingPlugins) { + LinearProgressIndicator(Modifier.fillMaxWidth().align(Alignment.TopCenter)) + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Components.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Components.kt new file mode 100644 index 00000000000..ee535169c1a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Components.kt @@ -0,0 +1,256 @@ +package com.lagradost.cloudstream4.desktop.ui + +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.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.height +import androidx.compose.foundation.layout.padding +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.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream4.compose.WhiteButton +import com.lagradost.cloudstream4.desktop.data.MediaEntry +import com.lagradost.cloudstream4.desktop.data.toMediaEntry + +@Composable +fun RemoteImage( + url: String?, + modifier: Modifier = Modifier, + headers: Map? = null, + contentScale: ContentScale = ContentScale.Crop, + contentDescription: String? = null, +) { + val context = LocalPlatformContext.current + val request = remember(url, headers) { + ImageRequest.Builder(context) + .data(url) + .crossfade(true) + .apply { + if (!headers.isNullOrEmpty()) { + httpHeaders(NetworkHeaders.Builder().apply { headers.forEach { (k, v) -> set(k, v) } }.build()) + } + } + .build() + } + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant), + ) +} + +@Composable +fun PosterCard( + title: String, + posterUrl: String?, + headers: Map?, + modifier: Modifier = Modifier, + width: Dp = 140.dp, + horizontal: Boolean = false, + subtitle: String? = null, + progress: Float? = null, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + Column( + modifier = modifier + .width(if (horizontal) width * 1.6f else width) + .clip(RoundedCornerShape(10.dp)) + .hoverable(interaction) + .clickable(onClick = onClick) + .padding(4.dp) + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(if (horizontal) 16f / 9f else 2f / 3f) + .clip(RoundedCornerShape(10.dp)) + ) { + RemoteImage(posterUrl, Modifier.fillMaxSize(), headers, contentDescription = title) + if (hovered) { + Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.12f))) + } + if (progress != null && progress > 0f) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(4.dp), + drawStopIndicator = {}, + ) + } + } + Spacer(Modifier.height(6.dp)) + Text( + title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontWeight = if (hovered) FontWeight.Bold else FontWeight.Normal, + ) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun SearchResponseCard(item: SearchResponse, horizontal: Boolean = false, width: Dp = 140.dp) { + val navigator = LocalNavigator.current + PosterCard( + title = item.name, + posterUrl = item.posterUrl, + headers = item.posterHeaders, + horizontal = horizontal, + width = width, + subtitle = listOfNotNull(item.type?.name, item.quality?.name).joinToString(" • ").ifBlank { null }, + onClick = { navigator.push(Route.Result(item.toMediaEntry())) }, + ) +} + +@Composable +fun MediaEntryCard(entry: MediaEntry, subtitle: String? = null, progress: Float? = null) { + val navigator = LocalNavigator.current + PosterCard( + title = entry.name, + posterUrl = entry.posterUrl, + headers = entry.posterHeaders, + subtitle = subtitle ?: entry.apiName, + progress = progress, + onClick = { navigator.push(Route.Result(entry)) }, + ) +} + +@Composable +fun SectionTitle(text: String, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { + Row( + modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + trailing() + } +} + +@Composable +fun SearchResponseRow(title: String, items: List, horizontal: Boolean = false) { + Column { + SectionTitle(title) + LazyRow( + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(items, key = { it.url + it.apiName }) { SearchResponseCard(it, horizontal) } + } + } +} + +@Composable +fun LoadingBox(text: String? = null, modifier: Modifier = Modifier.fillMaxSize()) { + Box(modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + if (text != null) { + Spacer(Modifier.height(12.dp)) + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +fun MessageBox( + title: String, + message: String? = null, + modifier: Modifier = Modifier.fillMaxSize(), + action: Pair Unit>? = null, +) { + Box(modifier.padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + if (message != null) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + if (action != null) { + Spacer(Modifier.height(4.dp)) + WhiteButton(action.first, onClick = action.second) + } + } + } +} + +@Composable +fun Pill(text: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Text(text, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) + } +} + +val BottomFade = Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))) + +/** Desktop text fields do not reliably fire IME actions, so handle Enter directly */ +fun Modifier.onEnter(action: () -> Unit): Modifier = onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.NumPadEnter)) { + action() + true + } else false +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/DownloadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/DownloadsScreen.kt new file mode 100644 index 00000000000..520b8938ffc --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/DownloadsScreen.kt @@ -0,0 +1,160 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.background +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.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.cloudstream4.desktop.downloads.DownloadItem +import com.lagradost.cloudstream4.desktop.downloads.DownloadManager +import com.lagradost.cloudstream4.desktop.downloads.DownloadStatus +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.delete +import com.lagradost.cloudstream4.desktop.generated.resources.download +import com.lagradost.cloudstream4.desktop.generated.resources.pause +import com.lagradost.cloudstream4.desktop.generated.resources.play +import org.jetbrains.compose.resources.painterResource +import java.awt.Desktop + +private fun formatBytes(bytes: Long): String = when { + bytes >= 1L shl 30 -> "%.2f GB".format(bytes / (1L shl 30).toDouble()) + bytes >= 1L shl 20 -> "%.1f MB".format(bytes / (1L shl 20).toDouble()) + bytes >= 1L shl 10 -> "%d KB".format(bytes / (1L shl 10)) + else -> "$bytes B" +} + +@Composable +fun DownloadsScreen() { + val downloads by DownloadManager.downloads.collectAsState() + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Downloads", style = MaterialTheme.typography.headlineMedium) + Text(DownloadManager.directory.absolutePath, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + OutlinedButton(onClick = { + DownloadManager.directory.mkdirs() + runCatching { Desktop.getDesktop().open(DownloadManager.directory) } + }) { Text("Open folder") } + } + if (downloads.isEmpty()) { + MessageBox( + "Nothing downloaded yet", + "Use the download button on a movie or episode to watch it offline.", + ) + return + } + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + // Grouped by show, newest first + val groups = downloads.groupBy { it.media.url } + groups.forEach { (_, items) -> + item(key = "header" + items.first().media.url) { + Text(items.first().media.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + } + items(items.sortedWith(compareBy({ it.season }, { it.episode })), key = { it.id }) { DownloadRow(it) } + } + } + } +} + +@Composable +private fun DownloadRow(item: DownloadItem) { + val navigator = LocalNavigator.current + val playable = Playable(item.id, item.title, item.season, item.episode) + fun play() = navigator.push(Route.Player(item.media, listOf(playable), 0)) + + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = item.status == DownloadStatus.Completed) { play() } + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(110.dp).aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp))) { + RemoteImage(item.media.posterUrl, Modifier.fillMaxSize(), item.media.posterHeaders) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(item.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + val detail = when (item.status) { + DownloadStatus.Queued -> "Waiting" + DownloadStatus.Downloading -> if (item.totalBytes == 0L && item.segmentsTotal == 0) "Finding links..." + else "${(item.progress * 100).toInt()}% · ${formatBytes(item.downloadedBytes)}" + + (if (item.totalBytes > 0) " of ${formatBytes(item.totalBytes)}" else "") + + DownloadStatus.Paused -> "Paused · ${(item.progress * 100).toInt()}%" + DownloadStatus.Completed -> "Downloaded · ${formatBytes(item.file.length())}" + DownloadStatus.Failed -> "Failed: ${item.error}" + } + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = if (item.status == DownloadStatus.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (item.status == DownloadStatus.Downloading || item.status == DownloadStatus.Paused) { + LinearProgressIndicator(progress = { item.progress }, modifier = Modifier.fillMaxWidth(), drawStopIndicator = {}) + } + } + Spacer(Modifier.width(8.dp)) + when (item.status) { + DownloadStatus.Completed -> IconButton(onClick = ::play) { Icon(painterResource(Res.drawable.play), "Play") } + DownloadStatus.Downloading, DownloadStatus.Queued -> + IconButton(onClick = { DownloadManager.pause(item.id) }) { Icon(painterResource(Res.drawable.pause), "Pause") } + + DownloadStatus.Paused, DownloadStatus.Failed -> + TextButton(onClick = { DownloadManager.resume(item.id) }) { Text(if (item.status == DownloadStatus.Failed) "Retry" else "Resume") } + } + IconButton(onClick = { DownloadManager.delete(item.id) }) { Icon(painterResource(Res.drawable.delete), "Delete") } + } +} + +/** Small download button with live state, used on movies and episodes */ +@Composable +fun DownloadButton(media: com.lagradost.cloudstream4.desktop.data.MediaEntry, playable: Playable) { + val downloads by DownloadManager.downloads.collectAsState() + val item = downloads.firstOrNull { it.id == playable.data } + when (item?.status) { + null, DownloadStatus.Failed -> IconButton(onClick = { DownloadManager.enqueue(media, playable) }) { + Icon(painterResource(Res.drawable.download), "Download") + } + + DownloadStatus.Completed -> Text("Downloaded", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + DownloadStatus.Paused -> TextButton(onClick = { DownloadManager.resume(item.id) }) { Text("Resume") } + DownloadStatus.Queued, DownloadStatus.Downloading -> Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp)) { + CircularProgressIndicator(progress = { item.progress }, modifier = Modifier.size(28.dp)) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionSettings.kt new file mode 100644 index 00000000000..c3bb1980eb8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionSettings.kt @@ -0,0 +1,128 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 android.content.Context +import android.content.DesktopContext +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.arrow_back +import com.lagradost.cloudstream4.desktop.generated.resources.delete +import org.jetbrains.compose.resources.painterResource + +/** + * On Android extensions have their own settings screens, which cannot run on desktop. Their settings + * are plain key-value stores though, so they can be edited here (for example a login token). + */ +@Composable +fun ExtensionSettingsScreen(onBack: () -> Unit) { + // Bumped after every edit so the list re-reads the stores + var version by remember { mutableStateOf(0) } + val names = remember(version) { Context.preferenceNames() } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { Icon(painterResource(Res.drawable.arrow_back), "Back") } + Column { + Text("Extension settings", style = MaterialTheme.typography.headlineSmall) + Text( + "Values extensions saved through their settings screen. Restart the app after changing them.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(12.dp)) + if (names.isEmpty()) { + MessageBox("No extension has saved settings yet") + return + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 24.dp)) { + items(names, key = { it }) { name -> + PreferenceStoreCard(name, version) { version++ } + } + } + } +} + +@Composable +private fun PreferenceStoreCard(name: String, version: Int, onChanged: () -> Unit) { + val prefs = remember(name) { DesktopContext.getSharedPreferences(name, Context.MODE_PRIVATE) } + val entries = remember(name, version) { prefs.getAll().toSortedMap() } + var newKey by remember { mutableStateOf("") } + var newValue by remember { mutableStateOf("") } + + Column( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(10.dp)).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(name, style = MaterialTheme.typography.titleMedium) + entries.forEach { (key, value) -> + var text by remember(name, key, version) { mutableStateOf(value?.toString().orEmpty()) } + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = text, + onValueChange = { + text = it + // Keep the stored type for numbers and booleans so extensions can still read them + val editor = prefs.edit() + when (value) { + is Boolean -> it.toBooleanStrictOrNull()?.let { b -> editor.putBoolean(key, b) } + is Int -> it.toIntOrNull()?.let { n -> editor.putInt(key, n) } + is Long -> it.toLongOrNull()?.let { n -> editor.putLong(key, n) } + is Number -> it.toFloatOrNull()?.let { n -> editor.putFloat(key, n) } + else -> editor.putString(key, it) + } + editor.apply() + }, + label = { Text(key) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { + prefs.edit().remove(key).apply() + onChanged() + }) { Icon(painterResource(Res.drawable.delete), "Remove $key") } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField(newKey, { newKey = it }, label = { Text("New key, e.g. token") }, singleLine = true, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + OutlinedTextField(newValue, { newValue = it }, label = { Text("Value") }, singleLine = true, modifier = Modifier.weight(2f)) + Spacer(Modifier.width(8.dp)) + OutlinedButton( + onClick = { + prefs.edit().putString(newKey.trim(), newValue.trim()).apply() + newKey = "" + newValue = "" + onChanged() + }, + enabled = newKey.isNotBlank(), + ) { Text("Add") } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionsScreen.kt new file mode 100644 index 00000000000..b80635b0bd4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ExtensionsScreen.kt @@ -0,0 +1,367 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.background +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.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.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.RepositoryEntry +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.arrow_back +import com.lagradost.cloudstream4.desktop.generated.resources.delete +import com.lagradost.cloudstream4.desktop.generated.resources.update +import com.lagradost.cloudstream4.desktop.plugins.PluginManager +import com.lagradost.cloudstream4.desktop.plugins.RemotePlugin +import com.lagradost.cloudstream4.desktop.plugins.Repository +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.launch +import org.jetbrains.compose.resources.painterResource + +val LocalSnackbar = compositionLocalOf { SnackbarHostState() } + +data class RepositoryPlugins( + val repository: Repository? = null, + val plugins: List = emptyList(), + val isLoading: Boolean = true, + val error: String? = null, +) + +data class ExtensionsState( + val openRepository: RepositoryEntry? = null, + val repositoryPlugins: Map = emptyMap(), + /** Internal names of extensions being installed or removed */ + val busy: Set = emptySet(), + val isAddingRepository: Boolean = false, + val isUpdating: Boolean = false, + val showSettings: Boolean = false, +) + +class ExtensionsViewModel(private val scope: CoroutineScope, private val notify: (String) -> Unit) { + private val _state = MutableStateFlow(ExtensionsState()) + val state: StateFlow = _state.asStateFlow() + + fun addRepository(input: String) { + if (input.isBlank()) return + _state.update { it.copy(isAddingRepository = true) } + scope.launch { + val repo = PluginManager.addRepository(input) + _state.update { it.copy(isAddingRepository = false) } + if (repo == null) notify("Not a valid repository: $input") else { + notify("Added ${repo.name}") + open(repo) + } + } + } + + fun removeRepository(repo: RepositoryEntry) = scope.launch { + PluginManager.removeRepository(repo.url) + _state.update { it.copy(openRepository = null, repositoryPlugins = it.repositoryPlugins - repo.url) } + notify("Removed ${repo.name}") + } + + fun showSettings(show: Boolean) = _state.update { it.copy(showSettings = show) } + + fun open(repo: RepositoryEntry?) { + _state.update { it.copy(openRepository = repo) } + if (repo != null && _state.value.repositoryPlugins[repo.url]?.error == null && + _state.value.repositoryPlugins[repo.url]?.plugins.isNullOrEmpty() + ) refresh(repo) + } + + fun refresh(repo: RepositoryEntry) { + _state.update { it.copy(repositoryPlugins = it.repositoryPlugins + (repo.url to RepositoryPlugins())) } + scope.launch { + val repository = PluginManager.fetchRepository(repo.url) + val result = if (repository == null) { + RepositoryPlugins(isLoading = false, error = "Could not load the repository") + } else { + RepositoryPlugins(repository, PluginManager.fetchRepositoryPlugins(repository).sortedBy { it.name.lowercase() }, false) + } + _state.update { it.copy(repositoryPlugins = it.repositoryPlugins + (repo.url to result)) } + } + } + + private fun busy(name: String, isBusy: Boolean) = + _state.update { it.copy(busy = if (isBusy) it.busy + name else it.busy - name) } + + fun install(repo: RepositoryEntry, plugin: RemotePlugin) { + busy(plugin.internalName, true) + scope.launch { + PluginManager.install(repo.url, plugin) + .onSuccess { notify("Installed ${plugin.name}") } + .onFailure { notify("Failed to install ${plugin.name}: ${it.message}") } + busy(plugin.internalName, false) + } + } + + fun uninstall(repo: RepositoryEntry, plugin: RemotePlugin) { + val installed = PluginManager.installedFor(repo.url, plugin.internalName) ?: return + busy(plugin.internalName, true) + scope.launch { + PluginManager.uninstall(installed) + busy(plugin.internalName, false) + notify("Removed ${plugin.name}") + } + } + + fun installAll(repo: RepositoryEntry) { + val plugins = _state.value.repositoryPlugins[repo.url]?.plugins.orEmpty() + .filter { it.status != 0 && PluginManager.installedFor(repo.url, it.internalName) == null } + plugins.forEach { install(repo, it) } + } + + fun updateAll() { + _state.update { it.copy(isUpdating = true) } + scope.launch { + val count = PluginManager.updateAll() + _state.update { it.copy(isUpdating = false) } + notify(if (count == 0) "All extensions are up to date" else "Updated $count extensions") + } + } +} + +@Composable +fun ExtensionsScreen(viewModel: ExtensionsViewModel) { + val state by viewModel.state.collectAsState() + val open = state.openRepository + when { + state.showSettings -> ExtensionSettingsScreen(onBack = { viewModel.showSettings(false) }) + open == null -> RepositoryList(viewModel, state) + else -> RepositoryDetails(viewModel, state, open) + } +} + +@Composable +private fun RepositoryList(viewModel: ExtensionsViewModel, state: ExtensionsState) { + val repositories by AppData.repositories.collectAsState() + val plugins by AppData.plugins.collectAsState() + val errors by PluginManager.errors.collectAsState() + var input by remember { mutableStateOf("") } + + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp)) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Extensions", style = MaterialTheme.typography.headlineMedium, modifier = Modifier.weight(1f)) + OutlinedButton(onClick = { viewModel.showSettings(true) }) { Text("Extension settings") } + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = viewModel::updateAll, enabled = !state.isUpdating && plugins.isNotEmpty()) { + Icon(painterResource(Res.drawable.update), null) + Spacer(Modifier.width(6.dp)) + Text(if (state.isUpdating) "Updating..." else "Update all") + } + } + Text( + "${plugins.size} installed · ${PluginManager.providers().size} providers loaded", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + label = { Text("Repository URL or shortcode") }, + singleLine = true, + modifier = Modifier.weight(1f).onEnter { viewModel.addRepository(input).also { input = "" } }, + ) + Spacer(Modifier.width(12.dp)) + Button( + onClick = { + viewModel.addRepository(input) + input = "" + }, + enabled = !state.isAddingRepository && input.isNotBlank(), + ) { Text(if (state.isAddingRepository) "Adding..." else "Add repository") } + } + Spacer(Modifier.height(16.dp)) + } + + if (errors.isNotEmpty()) { + item { + Column( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(10.dp)).padding(12.dp) + ) { + Text("Some extensions failed to load", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onErrorContainer) + errors.forEach { Text("${it.name}: ${it.message}", color = MaterialTheme.colorScheme.onErrorContainer) } + } + Spacer(Modifier.height(16.dp)) + } + } + + if (repositories.isEmpty()) { + item { + MessageBox( + "No repositories added", + "Paste a repository link above. Repositories are published by the community, CloudStream does not provide any.", + Modifier.fillMaxWidth(), + ) + } + } + + items(repositories, key = { it.url }) { repo -> + val count = plugins.count { it.repositoryUrl == repo.url } + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp).clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { viewModel.open(repo) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RemoteImage(repo.iconUrl, Modifier.size(40.dp).clip(RoundedCornerShape(8.dp))) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(repo.name, style = MaterialTheme.typography.titleMedium) + Text(repo.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text("$count installed", color = MaterialTheme.colorScheme.onSurfaceVariant) + IconButton(onClick = { viewModel.removeRepository(repo) }) { + Icon(painterResource(Res.drawable.delete), "Remove repository") + } + } + } + } +} + +@Composable +private fun RepositoryDetails(viewModel: ExtensionsViewModel, state: ExtensionsState, repo: RepositoryEntry) { + val plugins by AppData.plugins.collectAsState() + val data = state.repositoryPlugins[repo.url] ?: RepositoryPlugins() + var query by remember { mutableStateOf("") } + var language by remember { mutableStateOf(null) } + val languages = remember(data.plugins) { data.plugins.mapNotNull { it.language }.distinct().sorted() } + val shown = data.plugins.filter { + (language == null || it.language == language) && + (query.isBlank() || it.name.contains(query, ignoreCase = true) || it.description?.contains(query, true) == true) + } + + Column(Modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { viewModel.open(null) }) { Icon(painterResource(Res.drawable.arrow_back), "Back") } + Column(Modifier.weight(1f)) { + Text(repo.name, style = MaterialTheme.typography.headlineSmall) + data.repository?.description?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + OutlinedButton(onClick = { viewModel.installAll(repo) }, enabled = !data.isLoading) { Text("Install all") } + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField(query, { query = it }, label = { Text("Filter") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + if (languages.size > 1) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(vertical = 8.dp), + ) { + item { FilterChip(language == null, { language = null }, label = { Text("All") }) } + items(languages) { lang -> FilterChip(language == lang, { language = lang }, label = { Text(lang) }) } + } + } + + when { + data.isLoading -> LoadingBox("Loading extensions") + data.error != null -> MessageBox(data.error, action = "Retry" to { viewModel.refresh(repo) }) + else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(shown, key = { it.internalName }) { plugin -> + val installed = plugins.firstOrNull { + it.filePath == PluginManager.pluginFile(repo.url, plugin.internalName).absolutePath + } + PluginRow( + plugin = plugin, + installedVersion = installed?.version, + isBusy = plugin.internalName in state.busy, + onInstall = { viewModel.install(repo, plugin) }, + onUninstall = { viewModel.uninstall(repo, plugin) }, + ) + } + } + } + } +} + +@Composable +private fun PluginRow( + plugin: RemotePlugin, + installedVersion: Int?, + isBusy: Boolean, + onInstall: () -> Unit, + onUninstall: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)).background(MaterialTheme.colorScheme.surfaceVariant).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RemoteImage(plugin.icon(), Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text(plugin.name, style = MaterialTheme.typography.titleMedium) + Text("v${plugin.version}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + plugin.language?.let { Pill(it) } + when (plugin.status) { + 0 -> Pill("Down") + 2 -> Pill("Slow") + 3 -> Pill("Beta") + } + if (plugin.jarUrl == null) Pill("Android build") + } + plugin.description?.let { + Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + val details = listOfNotNull( + plugin.authors.takeIf { it.isNotEmpty() }?.joinToString(prefix = "By "), + plugin.tvTypes?.takeIf { it.isNotEmpty() }?.joinToString(), + ) + if (details.isNotEmpty()) { + Text(details.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Spacer(Modifier.width(12.dp)) + Box(Modifier.width(120.dp), contentAlignment = Alignment.CenterEnd) { + when { + isBusy -> CircularProgressIndicator(Modifier.size(24.dp)) + installedVersion == null -> Button(onClick = onInstall, enabled = plugin.status != 0) { Text("Install") } + installedVersion < plugin.version -> Button(onClick = onInstall) { Text("Update") } + else -> OutlinedButton(onClick = onUninstall) { Text("Remove") } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/HomeScreen.kt new file mode 100644 index 00000000000..d9232010af5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/HomeScreen.kt @@ -0,0 +1,220 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +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.OutlinedButton +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.unit.dp +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageData +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.arrow_down +import com.lagradost.cloudstream4.desktop.generated.resources.refresh +import com.lagradost.cloudstream4.desktop.plugins.PluginManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import org.jetbrains.compose.resources.painterResource + +data class HomeState( + val provider: String? = null, + val rows: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +class HomeViewModel(private val scope: CoroutineScope) { + private val _state = MutableStateFlow(HomeState()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + + fun currentApi(): MainAPI? { + val providers = PluginManager.providers().filter { it.hasMainPage } + val wanted = _state.value.provider ?: AppData.settings.value.homeProvider + return providers.firstOrNull { it.name == wanted } ?: providers.firstOrNull() + } + + fun select(name: String) { + AppData.updateSettings { it.copy(homeProvider = name) } + _state.value = HomeState(provider = name) + load() + } + + /** Reloads if the provider disappeared or nothing was loaded yet */ + fun ensureLoaded() { + val api = currentApi() + val current = _state.value + if (api == null) { + job?.cancel() + _state.value = HomeState() + } else if (current.provider != api.name || (current.rows.isEmpty() && !current.isLoading && current.error == null)) { + _state.value = HomeState(provider = api.name) + load() + } + } + + fun load() { + val api = currentApi() ?: return + job?.cancel() + _state.value = HomeState(provider = api.name, isLoading = true) + job = scope.launch { + val lock = Mutex() + val errors = mutableListOf() + suspend fun fetch(data: MainPageData) { + try { + val response = withTimeout(api.getMainPageTimeoutMs ?: 60_000L) { + api.getMainPage(1, MainPageRequest(data.name, data.data, data.horizontalImages)) + } + val rows = response?.items.orEmpty().filter { it.list.isNotEmpty() } + lock.withLock { _state.update { it.copy(rows = it.rows + rows) } } + } catch (t: Throwable) { + if (t is CancellationException && t !is TimeoutCancellationException) throw t + logError(t) + lock.withLock { errors += t } + } + } + + if (api.sequentialMainPage) { + api.mainPage.forEach { + fetch(it) + delay(api.sequentialMainPageDelay) + } + } else { + api.mainPage.map { launch(Dispatchers.IO) { fetch(it) } }.forEach { it.join() } + } + + _state.update { + it.copy( + isLoading = false, + error = if (it.rows.isEmpty()) errors.firstOrNull()?.let { e -> e.message ?: e.javaClass.simpleName } + ?: "Nothing found" else null + ) + } + } + } +} + +@Composable +fun HomeScreen(viewModel: HomeViewModel) { + val navigator = LocalNavigator.current + val providersVersion by PluginManager.providersVersion.collectAsState() + val state by viewModel.state.collectAsState() + val history by AppData.history.collectAsState() + val positions by AppData.positions.collectAsState() + + LaunchedEffect(providersVersion) { viewModel.ensureLoaded() } + val providers = remember(providersVersion) { PluginManager.providers().filter { it.hasMainPage } } + + if (providers.isEmpty()) { + val anyProviders = remember(providersVersion) { PluginManager.providers().isNotEmpty() } + MessageBox( + "No extensions with a home page installed", + if (anyProviders) "Your installed extensions only support search, use the Search tab or install more extensions." + else "Add a repository and install an extension (a site provider, not a repository helper) to start browsing.", + action = "Open extensions" to { navigator.select(Tab.Extensions) }, + ) + return + } + + Box(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize()) { + item { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { expanded = true }) { + Text(state.provider ?: "Select provider") + Spacer(Modifier.width(4.dp)) + Icon(painterResource(Res.drawable.arrow_down), null) + } + DropdownMenu(expanded, onDismissRequest = { expanded = false }) { + providers.sortedBy { it.name.lowercase() }.forEach { api -> + DropdownMenuItem( + text = { Text("${api.name} · ${api.lang}") }, + onClick = { + expanded = false + viewModel.select(api.name) + }, + ) + } + } + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = viewModel::load) { Icon(painterResource(Res.drawable.refresh), "Reload") } + } + if (state.isLoading) LinearProgressIndicator(Modifier.fillMaxWidth()) + } + + if (history.isNotEmpty()) { + item { + SectionTitle("Continue watching") + LazyRow(contentPadding = PaddingValues(horizontal = 12.dp)) { + items(history.take(20), key = { it.media.url }) { progress -> + MediaEntryCard( + progress.media, + subtitle = progress.episodeName ?: progress.media.apiName, + progress = progress.episodeData?.let { positions[it]?.fraction }, + ) + } + } + } + } + + items(state.rows, key = { it.name + it.list.firstOrNull()?.url }) { row -> + SearchResponseRow(row.name, row.list, row.isHorizontalImages) + } + + if (state.error != null) { + item { + MessageBox( + "Could not load ${state.provider}", + state.error, + Modifier.fillMaxWidth(), + action = "Retry" to viewModel::load, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/LibraryScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/LibraryScreen.kt new file mode 100644 index 00000000000..2f94c793917 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/LibraryScreen.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.desktop.data.AppData + +private enum class LibraryTab(val title: String) { Bookmarks("Bookmarks"), History("History") } + +@Composable +fun LibraryScreen() { + val bookmarks by AppData.bookmarks.collectAsState() + val history by AppData.history.collectAsState() + val positions by AppData.positions.collectAsState() + var tab by remember { mutableStateOf(LibraryTab.Bookmarks) } + + Column(Modifier.fillMaxSize()) { + Row( + Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LibraryTab.entries.forEach { + FilterChip(tab == it, onClick = { tab = it }, label = { Text(it.title) }) + } + if (tab == LibraryTab.History && history.isNotEmpty()) { + TextButton(onClick = AppData::clearHistory) { Text("Clear history") } + } + } + + val empty = if (tab == LibraryTab.Bookmarks) bookmarks.isEmpty() else history.isEmpty() + if (empty) { + MessageBox( + if (tab == LibraryTab.Bookmarks) "No bookmarks yet" else "Nothing watched yet", + if (tab == LibraryTab.Bookmarks) "Bookmark something from its page to find it here." else null, + ) + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(150.dp), + contentPadding = PaddingValues(12.dp), + modifier = Modifier.fillMaxSize(), + ) { + when (tab) { + LibraryTab.Bookmarks -> items(bookmarks, key = { it.url }) { MediaEntryCard(it) } + LibraryTab.History -> items(history, key = { it.media.url }) { progress -> + MediaEntryCard( + progress.media, + subtitle = progress.episodeName ?: progress.media.apiName, + progress = progress.episodeData?.let { positions[it]?.fraction }, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Navigation.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Navigation.kt new file mode 100644 index 00000000000..a44095aa000 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/Navigation.kt @@ -0,0 +1,64 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.lagradost.cloudstream4.desktop.data.MediaEntry + +enum class Tab(val title: String) { + Home("Home"), + Search("Search"), + Library("Library"), + Downloads("Downloads"), + Extensions("Extensions"), + Settings("Settings"), +} + +/** A single episode or movie that can be played */ +data class Playable( + val data: String, + val title: String, + val season: Int? = null, + val episode: Int? = null, + val posterUrl: String? = null, +) + +sealed interface Route { + data class Result(val media: MediaEntry) : Route + + /** [index] is the item in [playlist] to start with, so next/previous episode works */ + data class Player(val media: MediaEntry, val playlist: List, val index: Int) : Route +} + +class Navigator { + var tab by mutableStateOf(Tab.Home) + private set + val stack = mutableStateListOf() + + val current: Route? get() = stack.lastOrNull() + + fun select(tab: Tab) { + this.tab = tab + stack.clear() + } + + fun push(route: Route) { + stack.add(route) + } + + /** Replace the top of the stack, used when switching episodes in the player */ + fun replace(route: Route) { + if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex) + stack.add(route) + } + + fun back(): Boolean { + if (stack.isEmpty()) return false + stack.removeAt(stack.lastIndex) + return true + } +} + +val LocalNavigator = compositionLocalOf { error("No navigator") } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt new file mode 100644 index 00000000000..5f7efe7bab4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt @@ -0,0 +1,473 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Spacer +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.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +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.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.PlayerKind +import com.lagradost.cloudstream4.desktop.data.WatchProgress +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.arrow_back +import com.lagradost.cloudstream4.desktop.generated.resources.audio +import com.lagradost.cloudstream4.desktop.generated.resources.forward +import com.lagradost.cloudstream4.desktop.generated.resources.fullscreen +import com.lagradost.cloudstream4.desktop.generated.resources.fullscreen_exit +import com.lagradost.cloudstream4.desktop.generated.resources.pause +import com.lagradost.cloudstream4.desktop.generated.resources.play +import com.lagradost.cloudstream4.desktop.generated.resources.replay +import com.lagradost.cloudstream4.desktop.generated.resources.skip_next +import com.lagradost.cloudstream4.desktop.generated.resources.sources +import com.lagradost.cloudstream4.desktop.generated.resources.speed +import com.lagradost.cloudstream4.desktop.generated.resources.subtitles +import com.lagradost.cloudstream4.desktop.generated.resources.volume_mute +import com.lagradost.cloudstream4.desktop.generated.resources.volume_up +import com.lagradost.cloudstream4.desktop.player.ExternalPlayer +import com.lagradost.cloudstream4.desktop.player.Vlc +import com.lagradost.cloudstream4.desktop.player.VlcEngine +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import java.awt.Cursor +import java.awt.Point +import java.awt.Toolkit +import java.awt.image.BufferedImage +import java.io.File + +val LocalWindowState = compositionLocalOf { null } + +private val hiddenCursor by lazy { + PointerIcon( + Toolkit.getDefaultToolkit().createCustomCursor( + BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB), Point(0, 0), "hidden" + ) + ) +} + +private fun formatTime(ms: Long): String { + val total = (ms / 1000).coerceAtLeast(0) + val hours = total / 3600 + val minutes = (total % 3600) / 60 + val seconds = total % 60 + return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%02d:%02d".format(minutes, seconds) +} + +@Composable +fun PlayerScreen(route: Route.Player) { + val navigator = LocalNavigator.current + val settings by AppData.settings.collectAsState() + val playable = route.playlist[route.index] + val scope = rememberCoroutineScope() + val viewModel = remember(route) { PlayerViewModel(scope, route.media.apiName, playable.data) } + val state by viewModel.state.collectAsState() + LaunchedEffect(viewModel) { viewModel.loadLinks() } + + val hasNext = route.index + 1 < route.playlist.size + val playNext = { navigator.replace(route.copy(index = route.index + 1)) } + + if (settings.player == PlayerKind.External || !Vlc.isAvailable) { + ExternalPlayerScreen(route, viewModel, state, hasNext, playNext) + return + } + + val engine = remember { VlcEngine(Vlc.factory!!) } + val windowState = LocalWindowState.current + DisposableEffect(engine) { + onDispose { + engine.release() + if (windowState?.placement == WindowPlacement.Fullscreen) windowState.placement = WindowPlacement.Floating + } + } + + fun saveProgress() { + if (engine.lengthMs <= 0) return + AppData.saveProgress( + WatchProgress( + media = route.media, + episodeName = if (route.playlist.size > 1 || playable.episode != null) playable.title else null, + episodeData = playable.data, + season = playable.season, + episode = playable.episode, + positionMs = engine.timeMs, + durationMs = engine.lengthMs, + ) + ) + } + + // Start (or switch) playback whenever the selected link changes, resuming where the user left off + LaunchedEffect(state.selectedLink) { + val link = state.selectedLink ?: return@LaunchedEffect + val resume = AppData.positions.value[playable.data]?.takeIf { it.fraction < 0.95f }?.positionMs ?: 0L + engine.play(link.playbackUrl(), if (engine.timeMs > 0) engine.timeMs else resume) + } + LaunchedEffect(engine.hasError) { + if (engine.hasError) viewModel.nextLink() + } + LaunchedEffect(engine.hasFinished) { + if (engine.hasFinished) { + saveProgress() + if (hasNext) playNext() + } + } + LaunchedEffect(engine) { + while (true) { + delay(5_000) + if (engine.isPlaying) saveProgress() + } + } + DisposableEffect(engine) { onDispose { saveProgress() } } + + var lastInteraction by remember { mutableStateOf(System.currentTimeMillis()) } + var now by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + delay(500) + now = System.currentTimeMillis() + } + } + val controlsVisible = !engine.isPlaying || now - lastInteraction < 3_000 + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + + val isFullscreen = windowState?.placement == WindowPlacement.Fullscreen + fun toggleFullscreen() { + windowState ?: return + windowState.placement = if (isFullscreen) WindowPlacement.Floating else WindowPlacement.Fullscreen + } + + val seekMs = settings.seekSeconds * 1000L + @OptIn(ExperimentalComposeUiApi::class) + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focus) + .focusable() + .onPointerEvent(PointerEventType.Move) { lastInteraction = System.currentTimeMillis() } + .pointerHoverIcon(if (controlsVisible) PointerIcon.Default else hiddenCursor) + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + lastInteraction = System.currentTimeMillis() + when (event.key) { + Key.Spacebar, Key.K -> engine.togglePause() + Key.DirectionLeft, Key.J -> engine.skip(-seekMs) + Key.DirectionRight, Key.L -> engine.skip(seekMs) + Key.DirectionUp -> engine.changeVolume(engine.volume + 5) + Key.DirectionDown -> engine.changeVolume(engine.volume - 5) + Key.M -> engine.changeVolume(if (engine.volume == 0) 100 else 0) + Key.F -> toggleFullscreen() + Key.N -> if (hasNext) playNext() + Key.Escape -> if (isFullscreen) toggleFullscreen() else navigator.back() + else -> return@onKeyEvent false + } + true + } + ) { + engine.frame?.let { frame -> + Image( + frame, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + engine.togglePause() + focus.requestFocus() + }, + ) + } + + if (state.selectedLink == null || engine.isBuffering) { + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + if (state.error == null) CircularProgressIndicator(color = Color.White) + Spacer(Modifier.height(12.dp)) + Text( + when { + state.error != null -> state.error!! + state.selectedLink == null -> "Loading links (${state.links.size} found)" + else -> "Buffering ${state.selectedLink?.label()}" + }, + color = Color.White, + ) + if (state.error != null) { + TextButton(onClick = viewModel::loadLinks) { Text("Retry") } + } + } + } + + AnimatedVisibility(controlsVisible, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize()) { + Row( + Modifier.fillMaxWidth().background(BottomFadeReversed).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { navigator.back() }) { + Icon(painterResource(Res.drawable.arrow_back), "Back", tint = Color.White) + } + Column(Modifier.weight(1f)) { + Text(playable.title, color = Color.White, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + state.selectedLink?.let { + Text(it.label(), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodySmall) + } + } + } + + Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(BottomFade).padding(horizontal = 16.dp, vertical = 8.dp)) { + var dragging by remember { mutableStateOf(null) } + val progress = if (engine.lengthMs > 0) engine.timeMs.toFloat() / engine.lengthMs else 0f + Row(verticalAlignment = Alignment.CenterVertically) { + Text(formatTime(dragging?.let { (it * engine.lengthMs).toLong() } ?: engine.timeMs), color = Color.White) + Slider( + value = dragging ?: progress, + onValueChange = { + dragging = it + lastInteraction = System.currentTimeMillis() + }, + onValueChangeFinished = { + dragging?.let { engine.seekTo((it * engine.lengthMs).toLong()) } + dragging = null + focus.requestFocus() + }, + enabled = engine.lengthMs > 0, + colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = MaterialTheme.colorScheme.primary), + modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + ) + Text(formatTime(engine.lengthMs), color = Color.White) + } + Row(verticalAlignment = Alignment.CenterVertically) { + ControlButton(if (engine.isPlaying) Res.drawable.pause else Res.drawable.play, "Play/Pause") { engine.togglePause() } + ControlButton(Res.drawable.replay, "Back ${settings.seekSeconds}s") { engine.skip(-seekMs) } + ControlButton(Res.drawable.forward, "Forward ${settings.seekSeconds}s") { engine.skip(seekMs) } + if (hasNext) ControlButton(Res.drawable.skip_next, "Next episode") { playNext() } + ControlButton(if (engine.volume == 0) Res.drawable.volume_mute else Res.drawable.volume_up, "Mute") { + engine.changeVolume(if (engine.volume == 0) 100 else 0) + } + Slider( + value = engine.volume / 200f, + onValueChange = { engine.changeVolume((it * 200).toInt()) }, + colors = SliderDefaults.colors(thumbColor = Color.White), + modifier = Modifier.width(120.dp), + ) + Text("${engine.volume}%", color = Color.White, style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.weight(1f)) + + MenuButton(Res.drawable.sources, "Sources (${state.links.size})") { close -> + if (state.isLoadingLinks) DropdownMenuItem(text = { Text("Still loading links...") }, onClick = {}, enabled = false) + state.links.forEach { link -> + DropdownMenuItem( + text = { Text(link.label(), fontWeight = if (link == state.selectedLink) FontWeight.Bold else null) }, + onClick = { + close() + viewModel.selectLink(link) + }, + ) + } + } + MenuButton(Res.drawable.subtitles, "Subtitles") { close -> + DropdownMenuItem(text = { Text("Off") }, onClick = { + close() + engine.selectSubtitle(-1) + viewModel.clearSubtitle() + }) + engine.subtitleTracks.filter { it.id >= 0 }.forEach { track -> + DropdownMenuItem( + text = { Text(track.name, fontWeight = if (track.id == engine.subtitleTrack) FontWeight.Bold else null) }, + onClick = { + close() + engine.selectSubtitle(track.id) + }, + ) + } + state.subtitles.forEach { subtitle -> + DropdownMenuItem( + text = { + Text(subtitle.lang, fontWeight = if (subtitle == state.selectedSubtitle) FontWeight.Bold else null) + }, + onClick = { + close() + scope.launch { viewModel.downloadSubtitle(subtitle)?.let(engine::addSubtitleFile) } + }, + ) + } + } + if (engine.audioTracks.size > 2) { + MenuButton(Res.drawable.audio, "Audio") { close -> + engine.audioTracks.filter { it.id >= 0 }.forEach { track -> + DropdownMenuItem( + text = { Text(track.name, fontWeight = if (track.id == engine.audioTrack) FontWeight.Bold else null) }, + onClick = { + close() + engine.selectAudio(track.id) + }, + ) + } + } + } + MenuButton(Res.drawable.speed, "Speed ${engine.rate}x") { close -> + listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f).forEach { rate -> + DropdownMenuItem(text = { Text("${rate}x") }, onClick = { + close() + engine.changeRate(rate) + }) + } + } + ControlButton(if (isFullscreen) Res.drawable.fullscreen_exit else Res.drawable.fullscreen, "Fullscreen") { + toggleFullscreen() + } + } + } + } + } + } +} + +private val BottomFadeReversed = Brush.verticalGradient( + listOf(Color.Black.copy(alpha = 0.7f), Color.Transparent) +) + +@Composable +private fun ControlButton(icon: DrawableResource, description: String, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(painterResource(icon), description, tint = Color.White) + } +} + +@Composable +private fun MenuButton(icon: DrawableResource, description: String, content: @Composable (close: () -> Unit) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { expanded = true }) { + Icon(painterResource(icon), null, tint = Color.White, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + Text(description, color = Color.White) + } + DropdownMenu(expanded, onDismissRequest = { expanded = false }) { + content { expanded = false } + } + } +} + +/** Used when VLC is not installed or the user prefers their own player */ +@Composable +private fun ExternalPlayerScreen( + route: Route.Player, + viewModel: PlayerViewModel, + state: PlayerState, + hasNext: Boolean, + playNext: () -> Unit, +) { + val navigator = LocalNavigator.current + val scope = rememberCoroutineScope() + val playable = route.playlist[route.index] + var launched by remember { mutableStateOf(null) } + + fun launch(link: ExtractorLink) { + scope.launch { + val subtitle: File? = state.subtitles.firstOrNull { it.langTag?.startsWith("en") == true } + ?.let { viewModel.downloadSubtitle(it) } + launched = if (ExternalPlayer.launch(link.playbackUrl(), playable.title, listOfNotNull(subtitle))) { + "Playing ${link.label()} in ${ExternalPlayer.detect()?.let { File(it).nameWithoutExtension }}" + } else { + "No external player found, install VLC or mpv, or set one in Settings" + } + } + } + + LaunchedEffect(state.isLoadingLinks) { + if (!state.isLoadingLinks && launched == null) state.links.firstOrNull()?.let(::launch) + } + + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { navigator.back() }) { Icon(painterResource(Res.drawable.arrow_back), "Back") } + Text(playable.title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + if (hasNext) TextButton(onClick = playNext) { Text("Next episode") } + } + if (!Vlc.isAvailable && AppData.settings.value.player == PlayerKind.Internal) { + Text( + "The built-in player needs VLC installed. Install it from videolan.org to play inside CloudStream.", + color = MaterialTheme.colorScheme.error, + ) + TextButton(onClick = { openInBrowser("https://www.videolan.org/vlc/") }) { Text("Download VLC") } + } + launched?.let { Text(it) } + if (state.isLoadingLinks) Text("Loading links (${state.links.size} found)...") + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(state.links, key = { it.url }) { link -> + Row( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp)) + .clickable { launch(link) }.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(painterResource(Res.drawable.play), null) + Spacer(Modifier.width(12.dp)) + Text(link.label()) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerViewModel.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerViewModel.kt new file mode 100644 index 00000000000..1c8822e7429 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerViewModel.kt @@ -0,0 +1,161 @@ +package com.lagradost.cloudstream4.desktop.ui + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream4.desktop.downloads.DownloadManager +import com.lagradost.cloudstream4.desktop.downloads.DownloadStatus +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.player.StreamProxy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.io.File + +data class PlayerState( + val links: List = emptyList(), + val subtitles: List = emptyList(), + val isLoadingLinks: Boolean = true, + val selectedLink: ExtractorLink? = null, + val selectedSubtitle: SubtitleFile? = null, + val error: String? = null, +) + +/** Links that a desktop player can open */ +private fun ExtractorLink.isPlayable(): Boolean = + this !is DrmExtractorLink && type in setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH) + +fun ExtractorLink.label(): String { + val quality = when { + quality <= 0 -> null + quality >= 2160 -> "4K" + else -> "${quality}p" + } + return listOfNotNull(name, quality, type.name.takeIf { type != ExtractorLinkType.VIDEO }).distinct().joinToString(" · ") +} + +/** The url the player should open, going through the header proxy when the extension needs headers */ +fun ExtractorLink.playbackUrl(): String = + if (headers.isEmpty() && referer.isBlank()) url else StreamProxy.proxy(url, headers, referer) + +class PlayerViewModel( + private val scope: CoroutineScope, + private val apiName: String, + private val data: String, +) { + private val _state = MutableStateFlow(PlayerState()) + val state: StateFlow = _state.asStateFlow() + + /** Plays the downloaded copy if there is one, so offline viewing needs no extension or network */ + private suspend fun loadDownloaded(): Boolean { + val item = DownloadManager.forEpisode(data)?.takeIf { it.status == DownloadStatus.Completed && it.file.exists() } + ?: return false + val link = newExtractorLink("Downloaded", "Downloaded", item.file.toURI().toString(), ExtractorLinkType.VIDEO) + val subtitles = item.subtitlePaths.map { File(it) }.filter { it.exists() }.map { file -> + // Files are named video.lang.ext + newSubtitleFile(file.name.removeSuffix(".${file.extension}").substringAfterLast('.'), file.toURI().toString()) + } + _state.value = PlayerState(links = listOf(link), subtitles = subtitles, isLoadingLinks = false, selectedLink = link) + return true + } + + fun loadLinks() { + scope.launch { + if (!loadDownloaded()) loadOnlineLinks() + } + } + + private fun loadOnlineLinks() { + val api = APIHolder.getApiFromNameNull(apiName) + if (api == null) { + _state.value = PlayerState(isLoadingLinks = false, error = "The extension $apiName is not loaded") + return + } + _state.value = PlayerState() + scope.launch(Dispatchers.IO) { + try { + withTimeout(api.loadLinksTimeoutMs ?: 120_000L) { + api.loadLinks( + data, + false, + subtitleCallback = { subtitle -> + _state.update { s -> + if (s.subtitles.any { it.url == subtitle.url }) s else s.copy(subtitles = s.subtitles + subtitle) + } + }, + callback = { link -> + if (link.isPlayable()) { + _state.update { s -> + if (s.links.any { it.url == link.url }) s + else s.copy(links = sortUrls((s.links + link).toSet())) + } + } + }, + ) + } + } catch (t: Throwable) { + if (t is CancellationException && t !is TimeoutCancellationException) throw t + logError(t) + } + _state.update { s -> + s.copy( + isLoadingLinks = false, + selectedLink = s.selectedLink ?: s.links.firstOrNull(), + error = if (s.links.isEmpty()) "No playable links found" else null, + ) + } + } + } + + fun selectLink(link: ExtractorLink) = _state.update { it.copy(selectedLink = link, error = null) } + + /** Called when the current link fails, moves on to the next one */ + fun nextLink(): Boolean { + val s = _state.value + val index = s.links.indexOf(s.selectedLink) + val next = s.links.getOrNull(index + 1) + _state.update { + if (next != null) it.copy(selectedLink = next) + else it.copy(error = if (it.isLoadingLinks) null else "None of the links could be played") + } + return next != null + } + + fun clearSubtitle() = _state.update { it.copy(selectedSubtitle = null) } + + /** VLC handles local files far better than remote ones, so subtitles are downloaded first */ + suspend fun downloadSubtitle(subtitle: SubtitleFile): File? = withContext(Dispatchers.IO) { + try { + if (subtitle.url.startsWith("file:")) { + return@withContext File(java.net.URI(subtitle.url)).also { _state.update { s -> s.copy(selectedSubtitle = subtitle) } } + } + val text = app.get(subtitle.url, headers = subtitle.headers.orEmpty()).text + val extension = when { + text.trimStart().startsWith("WEBVTT") -> "vtt" + text.contains("[Script Info]") -> "ass" + else -> subtitle.url.substringBefore('?').substringAfterLast('.', "srt").take(4).lowercase() + } + val dir = File(AppDirs.cacheDir, "subtitles").also { it.mkdirs() } + File(dir, "${subtitle.url.hashCode()}.$extension").apply { writeText(text) } + .also { _state.update { s -> s.copy(selectedSubtitle = subtitle) } } + } catch (t: Throwable) { + logError(t) + null + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ResultScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ResultScreen.kt new file mode 100644 index 00000000000..b7b51338e7d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/ResultScreen.kt @@ -0,0 +1,406 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.background +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.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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.draw.clip +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 androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.APIHolder +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.MovieLoadResponse +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.MediaEntry +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.arrow_back +import com.lagradost.cloudstream4.desktop.generated.resources.bookmark +import com.lagradost.cloudstream4.desktop.generated.resources.bookmark_border +import com.lagradost.cloudstream4.desktop.generated.resources.open_in_new +import com.lagradost.cloudstream4.desktop.generated.resources.play +import com.lagradost.cloudstream4.desktop.generated.resources.star +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.jetbrains.compose.resources.painterResource +import java.awt.Desktop +import java.net.URI + +private sealed interface ResultState { + data object Loading : ResultState + data class Error(val message: String) : ResultState + data class Loaded(val response: LoadResponse) : ResultState +} + +/** Episodes grouped by dub status and season, as shown in the season/dub selectors */ +private data class EpisodeGroups( + val byDub: Map>>, + val seasonNames: Map, +) + +private fun LoadResponse.episodeGroups(): EpisodeGroups? { + val episodes: Map> = when (this) { + is TvSeriesLoadResponse -> mapOf(DubStatus.None to episodes) + is AnimeLoadResponse -> episodes.filterValues { it.isNotEmpty() } + else -> return null + } + val names = (this as? EpisodeResponse)?.seasonNames.orEmpty().associate { data -> + data.season to (data.name ?: "Season ${data.displaySeason ?: data.season}") + } + return EpisodeGroups( + byDub = episodes.mapValues { (_, list) -> + list.groupBy { it.season }.toSortedMap(compareBy(nullsFirst()) { it }) + }, + seasonNames = names, + ) +} + +private fun Episode.label(index: Int): String { + val number = episode ?: (index + 1) + val prefix = if (season != null) "S$season E$number" else "Episode $number" + return if (name.isNullOrBlank()) prefix else "$prefix · $name" +} + +private suspend fun load(media: MediaEntry): ResultState { + val api = APIHolder.getApiFromNameNull(media.apiName) + ?: return ResultState.Error("The extension ${media.apiName} is not installed or failed to load") + return try { + val response = withContext(Dispatchers.IO) { + withTimeout(api.loadTimeoutMs ?: 60_000L) { api.load(media.url) } + } + response?.let { ResultState.Loaded(it) } ?: ResultState.Error("Nothing returned by ${api.name}") + } catch (t: Throwable) { + logError(t) + ResultState.Error(t.message ?: t.javaClass.simpleName) + } +} + +fun openInBrowser(url: String) { + try { + Desktop.getDesktop().browse(URI(url)) + } catch (t: Throwable) { + logError(t) + } +} + +@Composable +fun ResultScreen(media: MediaEntry) { + val navigator = LocalNavigator.current + var state by remember(media) { mutableStateOf(ResultState.Loading) } + var reloadKey by remember { mutableStateOf(0) } + LaunchedEffect(media, reloadKey) { + state = ResultState.Loading + state = load(media) + } + + Box(Modifier.fillMaxSize()) { + when (val current = state) { + ResultState.Loading -> LoadingBox("Loading ${media.name}") + is ResultState.Error -> MessageBox( + "Could not load ${media.name}", + current.message, + action = "Retry" to { reloadKey++ }, + ) + + is ResultState.Loaded -> ResultContent(media, current.response) + } + IconButton( + onClick = { navigator.back() }, + modifier = Modifier.padding(8.dp).background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(50)), + ) { + Icon(painterResource(Res.drawable.arrow_back), "Back", tint = Color.White) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ResultContent(media: MediaEntry, response: LoadResponse) { + val navigator = LocalNavigator.current + val bookmarks by AppData.bookmarks.collectAsState() + val positions by AppData.positions.collectAsState() + val entry = remember(response) { + media.copy( + name = response.name, + posterUrl = response.posterUrl ?: media.posterUrl, + type = response.type, + posterHeaders = response.posterHeaders ?: media.posterHeaders, + ) + } + val groups = remember(response) { response.episodeGroups() } + var dub by remember(groups) { + mutableStateOf(groups?.byDub?.keys?.let { keys -> keys.firstOrNull { it == DubStatus.Subbed } ?: keys.firstOrNull() }) + } + val seasons = groups?.byDub?.get(dub).orEmpty() + var season by remember(groups, dub) { mutableStateOf(seasons.keys.firstOrNull()) } + val episodes = seasons[season].orEmpty() + + fun playEpisodes(list: List, index: Int) { + val playlist = list.mapIndexed { i, episode -> + Playable(episode.data, "${response.name} · ${episode.label(i)}", episode.season, episode.episode, episode.posterUrl) + } + navigator.push(Route.Player(entry, playlist, index)) + } + + val movieData: String? = when (response) { + is MovieLoadResponse -> response.dataUrl + is LiveStreamLoadResponse -> response.dataUrl + else -> null + } + + LazyColumn(Modifier.fillMaxSize()) { + item { + Box(Modifier.fillMaxWidth().height(360.dp)) { + RemoteImage( + response.backgroundPosterUrl ?: response.posterUrl, + Modifier.fillMaxSize(), + response.posterHeaders, + ) + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient(listOf(Color.Transparent, MaterialTheme.colorScheme.background)) + ) + ) + Row( + Modifier.align(Alignment.BottomStart).padding(24.dp), + verticalAlignment = Alignment.Bottom, + ) { + RemoteImage( + response.posterUrl, + Modifier.width(150.dp).aspectRatio(2f / 3f).clip(RoundedCornerShape(10.dp)), + response.posterHeaders, + ) + Spacer(Modifier.width(20.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(response.name, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + listOfNotNull( + response.year?.toString(), + response.type.name, + response.duration?.let { "$it min" }, + response.contentRating, + response.apiName, + ).forEach { Pill(it) } + response.score?.let { score -> + Icon(painterResource(Res.drawable.star), null, Modifier.size(18.dp)) + Text(score.toString()) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + when { + movieData != null -> Button(onClick = { + navigator.push( + Route.Player(entry, listOf(Playable(movieData, response.name, posterUrl = response.posterUrl)), 0) + ) + }) { + Icon(painterResource(Res.drawable.play), null) + Spacer(Modifier.width(6.dp)) + Text(if (positions[movieData]?.positionMs?.let { it > 0 } == true) "Resume" else "Play") + } + + response is TorrentLoadResponse -> Button(onClick = { + (response.magnet ?: response.torrent)?.let(::openInBrowser) + }) { Text("Open in torrent client") } + + episodes.isNotEmpty() -> Button(onClick = { + // Continue with the first unfinished episode of this season + val index = episodes.indexOfFirst { (positions[it.data]?.fraction ?: 0f) < 0.9f } + playEpisodes(episodes, index.coerceAtLeast(0)) + }) { + Icon(painterResource(Res.drawable.play), null) + Spacer(Modifier.width(6.dp)) + Text("Play") + } + } + if (movieData != null && response !is LiveStreamLoadResponse) { + DownloadButton(entry, Playable(movieData, response.name, posterUrl = response.posterUrl)) + } + val saved = bookmarks.any { it.url == media.url } + OutlinedButton(onClick = { AppData.toggleBookmark(entry) }) { + Icon(painterResource(if (saved) Res.drawable.bookmark else Res.drawable.bookmark_border), null) + Spacer(Modifier.width(6.dp)) + Text(if (saved) "Bookmarked" else "Bookmark") + } + OutlinedButton(onClick = { openInBrowser(response.url) }) { + Icon(painterResource(Res.drawable.open_in_new), null) + Spacer(Modifier.width(6.dp)) + Text("Website") + } + } + } + } + } + } + + item { + Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (response.comingSoon) Pill("Coming soon") + response.plot?.let { + Text(it, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.widthIn(max = 900.dp)) + } + response.tags?.takeIf { it.isNotEmpty() }?.let { tags -> + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + tags.forEach { Pill(it) } + } + } + response.actors?.takeIf { it.isNotEmpty() }?.let { actors -> + Text( + "Cast: " + actors.take(12).joinToString(", ") { it.actor.name }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (groups != null) { + item { + Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (groups.byDub.size > 1) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + groups.byDub.keys.forEach { status -> + FilterChip(dub == status, onClick = { dub = status }, label = { Text(status.name) }) + } + } + } + if (seasons.size > 1) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(seasons.keys.toList()) { key -> + FilterChip( + season == key, + onClick = { season = key }, + label = { Text(key?.let { groups.seasonNames[it] ?: "Season $it" } ?: "Episodes") }, + ) + } + } + } + if (episodes.isEmpty()) Text("No episodes found", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + items(episodes.size, key = { episodes[it].data + it }) { index -> + val episode = episodes[index] + EpisodeRow( + download = { + DownloadButton( + entry, + Playable(episode.data, "${response.name} · ${episode.label(index)}", episode.season, episode.episode, episode.posterUrl), + ) + }, + title = episode.label(index), + description = episode.description, + posterUrl = episode.posterUrl ?: response.posterUrl, + headers = response.posterHeaders, + progress = positions[episode.data]?.fraction, + onClick = { playEpisodes(episodes, index) }, + ) + } + } + + response.recommendations?.takeIf { it.isNotEmpty() }?.let { recommendations -> + item { + Spacer(Modifier.height(16.dp)) + SearchResponseRow("Recommended", recommendations) + Spacer(Modifier.height(24.dp)) + } + } + item { Spacer(Modifier.height(32.dp)) } + } +} + +@Composable +private fun EpisodeRow( + download: @Composable () -> Unit, + title: String, + description: String?, + posterUrl: String?, + headers: Map?, + progress: Float?, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onClick) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(180.dp).aspectRatio(16f / 9f).clip(RoundedCornerShape(8.dp))) { + RemoteImage(posterUrl, Modifier.fillMaxSize(), headers) + Icon( + painterResource(Res.drawable.play), + null, + Modifier.align(Alignment.Center).size(36.dp) + .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(50)).padding(6.dp), + tint = Color.White, + ) + if (progress != null && progress > 0f) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(4.dp), + drawStopIndicator = {}, + ) + } + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + description?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + download() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SearchScreen.kt new file mode 100644 index 00000000000..49c7ba7cf36 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SearchScreen.kt @@ -0,0 +1,161 @@ +package com.lagradost.cloudstream4.desktop.ui + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream4.desktop.generated.resources.Res +import com.lagradost.cloudstream4.desktop.generated.resources.search +import com.lagradost.cloudstream4.desktop.plugins.PluginManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.jetbrains.compose.resources.painterResource + +data class ProviderResults(val provider: String, val items: List, val error: String? = null) + +enum class SearchFilter(val title: String, val types: Set?) { + All("All", null), + Movies("Movies", setOf(TvType.Movie, TvType.AnimeMovie, TvType.Documentary, TvType.Torrent)), + Series("TV Series", setOf(TvType.TvSeries, TvType.Cartoon, TvType.AsianDrama, TvType.Documentary)), + Anime("Anime", setOf(TvType.Anime, TvType.AnimeMovie, TvType.OVA)), + Live("Live", setOf(TvType.Live)), +} + +data class SearchState( + val query: String = "", + val filter: SearchFilter = SearchFilter.All, + val results: List = emptyList(), + val pending: Int = 0, +) + +class SearchViewModel(private val scope: CoroutineScope) { + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + + fun onQueryChange(query: String) = _state.update { it.copy(query = query) } + + fun onFilterChange(filter: SearchFilter) { + _state.update { it.copy(filter = filter) } + if (_state.value.results.isNotEmpty()) search() + } + + fun search() { + val query = _state.value.query.trim() + if (query.isEmpty()) return + val filter = _state.value.filter + val providers = PluginManager.providers().filter { api -> + filter.types == null || api.supportedTypes.any { it in filter.types } + } + job?.cancel() + _state.update { it.copy(results = emptyList(), pending = providers.size) } + job = scope.launch { + providers.forEach { api -> + launch(Dispatchers.IO) { + val result = try { + val items = withTimeout(api.searchTimeoutMs ?: 30_000L) { api.search(query, 1) }?.items.orEmpty() + ProviderResults(api.name, items.filter { item -> + filter.types == null || item.type == null || item.type in filter.types + }) + } catch (t: Throwable) { + if (t is CancellationException && t !is TimeoutCancellationException) throw t + logError(t) + ProviderResults(api.name, emptyList(), t.message ?: t.javaClass.simpleName) + } + _state.update { + it.copy( + // Providers with results first, keep the order they arrive in + results = (it.results + result).sortedBy { r -> r.items.isEmpty() }, + pending = it.pending - 1, + ) + } + } + } + } + } +} + +@Composable +fun SearchScreen(viewModel: SearchViewModel) { + val state by viewModel.state.collectAsState() + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + + Column(Modifier.fillMaxSize()) { + OutlinedTextField( + value = state.query, + onValueChange = viewModel::onQueryChange, + placeholder = { Text("Search all extensions") }, + leadingIcon = { Icon(painterResource(Res.drawable.search), null) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { viewModel.search() }), + modifier = Modifier.fillMaxWidth().padding(16.dp).focusRequester(focus) + .onEnter { viewModel.search() }, + ) + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SearchFilter.entries.forEach { filter -> + FilterChip( + selected = state.filter == filter, + onClick = { viewModel.onFilterChange(filter) }, + label = { Text(filter.title) }, + ) + } + } + if (state.pending > 0) LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) + + if (PluginManager.providers().isEmpty()) { + MessageBox("No extensions installed", "Install extensions from the Extensions tab to search.") + return + } + + LazyColumn(Modifier.fillMaxSize()) { + items(state.results, key = { it.provider }) { result -> + when { + result.items.isNotEmpty() -> SearchResponseRow(result.provider, result.items) + else -> Text( + "${result.provider}: ${result.error ?: "No results"}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt new file mode 100644 index 00000000000..cb3254ea8c9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt @@ -0,0 +1,161 @@ +package com.lagradost.cloudstream4.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.Spacer +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.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.data.PlayerKind +import com.lagradost.cloudstream4.desktop.downloads.DownloadManager +import com.lagradost.cloudstream4.desktop.player.ExternalPlayer +import com.lagradost.cloudstream4.desktop.player.Vlc +import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor +import com.lagradost.cloudstream4.theme.CloudStreamThemeMode +import java.awt.Desktop + +@Composable +private fun Section(title: String, content: @Composable () -> Unit) { + Column(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + content() + } +} + +@Composable +private fun SwitchRow(title: String, subtitle: String?, checked: Boolean, onChange: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth().clickable { onChange(!checked) }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title) + subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + Switch(checked, onChange) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SettingsScreen() { + val settings by AppData.settings.collectAsState() + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp).widthIn(max = 800.dp) + ) { + Text("Settings", style = MaterialTheme.typography.headlineMedium) + + Section("Appearance") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + CloudStreamThemeMode.entries.filter { it != CloudStreamThemeMode.Dynamic }.forEach { mode -> + FilterChip( + settings.theme == mode, + onClick = { AppData.updateSettings { it.copy(theme = mode) } }, + label = { Text(if (mode == CloudStreamThemeMode.FollowSystem) "System" else mode.name) }, + ) + } + } + Text("Accent color", style = MaterialTheme.typography.bodyMedium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + CloudStreamPrimaryColor.entries.forEach { color -> + Box( + Modifier.size(32.dp).background(color.color, CircleShape) + .border( + if (settings.primaryColor == color) 3.dp else 0.dp, + if (settings.primaryColor == color) MaterialTheme.colorScheme.onBackground else Color.Transparent, + CircleShape, + ) + .clickable { AppData.updateSettings { it.copy(primaryColor = color) } } + ) + } + } + } + + Section("Player") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(settings.player == PlayerKind.Internal, { AppData.updateSettings { it.copy(player = PlayerKind.Internal) } }, + label = { Text("Built-in (VLC)") }) + FilterChip(settings.player == PlayerKind.External, { AppData.updateSettings { it.copy(player = PlayerKind.External) } }, + label = { Text("External player") }) + } + Text( + if (Vlc.isAvailable) "VLC found, the built-in player is available." + else "VLC was not found. Install VLC to use the built-in player.", + style = MaterialTheme.typography.bodySmall, + color = if (Vlc.isAvailable) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + ) + if (!Vlc.isAvailable) TextButton(onClick = { openInBrowser("https://www.videolan.org/vlc/") }) { Text("Download VLC") } + OutlinedTextField( + value = settings.externalPlayer, + onValueChange = { value -> AppData.updateSettings { it.copy(externalPlayer = value) } }, + label = { Text("External player path") }, + placeholder = { Text(ExternalPlayer.detect() ?: "Auto detect VLC, mpv or IINA") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text("Seek ${settings.seekSeconds} seconds with the arrow keys", style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(5, 10, 15, 30).forEach { seconds -> + FilterChip(settings.seekSeconds == seconds, { AppData.updateSettings { it.copy(seekSeconds = seconds) } }, + label = { Text("${seconds}s") }) + } + } + } + + Section("Downloads") { + OutlinedTextField( + value = settings.downloadDirectory, + onValueChange = { value -> AppData.updateSettings { it.copy(downloadDirectory = value) } }, + label = { Text("Download folder") }, + placeholder = { Text(DownloadManager.defaultDirectory.absolutePath) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Section("Extensions") { + SwitchRow( + "Use jsDelivr for GitHub", + "Downloads repositories and extensions through cdn.jsdelivr.net, useful if GitHub is blocked", + settings.jsdelivrProxy, + ) { value -> AppData.updateSettings { it.copy(jsdelivrProxy = value) } } + } + + Section("Storage") { + Text("Data folder: ${AppDirs.dataDir.absolutePath}", style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { runCatching { Desktop.getDesktop().open(AppDirs.dataDir) } }) { Text("Open data folder") } + } + + Section("About") { + Text("CloudStream for desktop, running on ${AppDirs.os.name}", style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = { openInBrowser("https://github.com/recloudstream/cloudstream") }) { Text("Source code") } + } + Spacer(Modifier.height(32.dp)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f1b45e6b67..8f999f39879 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything constraintlayout = "2.2.1" coreKtx = "1.18.0" cryptography = "0.6.0" +dex2jar = "2.4.28" desugar_jdk_libs_nio = "2.1.5" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" @@ -53,6 +54,7 @@ shimmer = "0.5.0" torrentserver = "7861970" tvprovider = "1.1.0" video = "1.0.0" +vlcj = "4.11.0" workRuntimeKtx = "2.11.2" zipline = "1.27.0" composeMaterial3 = "1.12.0-alpha03" @@ -89,6 +91,7 @@ core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography" } cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" } databinding = { module = "androidx.databinding:viewbinding", version.ref = "androidGradlePlugin" } +dex2jar-translator = { module = "de.femtopedia.dex2jar:dex-translator", version.ref = "dex2jar" } desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } @@ -145,6 +148,7 @@ shimmer = { module = "com.facebook.shimmer:shimmer", version.ref = "shimmer" } torrentserver = { module = "com.github.recloudstream:torrentserver", version.ref = "torrentserver" } tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } video = { module = "com.google.android.mediahome:video", version.ref = "video" } +vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } From f80e5673c1becbb5d7fcc52a833756396c93fb55 Mon Sep 17 00:00:00 2001 From: atomic-crafter <64207317+atomic-crafter@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:24:43 +0100 Subject: [PATCH 2/5] Desktop: auto-update from GitHub releases, StreamPlay compatibility - Check the fork's desktop-v* releases on startup, download in the background and install on restart or quit (dmg swap on macOS, silent msi on Windows, deb on Linux) - Version and update repository are generated at build time, CI takes the version from the tag - Context.getSystemService and ActivityManager memory info for extensions that size their concurrency by device memory - Build the installers on pushes to desktop-port --- .github/workflows/desktop.yml | 8 +- desktopApp/build.gradle.kts | 30 +- .../kotlin/com/lagradost/cloudstream4/Main.kt | 3 + .../cloudstream4/desktop/data/AppData.kt | 3 + .../lagradost/cloudstream4/desktop/ui/App.kt | 23 ++ .../cloudstream4/desktop/ui/SettingsScreen.kt | 47 ++++ .../cloudstream4/desktop/update/Updater.kt | 266 ++++++++++++++++++ 7 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/update/Updater.kt diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index c297f23307b..acf60104979 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -5,7 +5,7 @@ name: Desktop Installers on: workflow_dispatch: push: - branches: [master] + branches: [master, desktop-port] tags: ["desktop-v*"] paths: - "desktopApp/**" @@ -69,7 +69,10 @@ jobs: chmod +x gradlew tasks="" for task in ${{ matrix.tasks }}; do tasks="$tasks :desktopApp:$task"; done - ./gradlew $tasks --no-configuration-cache + # Release builds take their version from the tag, which is what the in-app updater compares + version="" + if [[ "$GITHUB_REF" == refs/tags/desktop-v* ]]; then version="-PdesktopVersion=${GITHUB_REF#refs/tags/desktop-v}"; fi + ./gradlew $tasks $version -PdesktopUpdateRepo=${{ github.repository }} --no-configuration-cache - name: Upload installers uses: actions/upload-artifact@v7 @@ -113,5 +116,6 @@ jobs: generate_release_notes: true body: | Unsigned builds. VLC must be installed for the built-in player. + Installed apps update themselves to new releases automatically. macOS: if it says the app is damaged, run `xattr -cr /Applications/CloudStream.app`. Windows: SmartScreen may warn, choose "More info" → "Run anyway". diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 5f9046566bd..50dc1054415 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -59,7 +59,35 @@ compose.resources { generateResClass = always } -val appVersion = libs.versions.versionName.get() +// CI passes the version from the release tag (desktop-v1.2.3 -> 1.2.3) so updates compare correctly +val appVersion = (findProperty("desktopVersion") as String?)?.takeIf { it.isNotBlank() } ?: libs.versions.versionName.get() +// GitHub repository whose releases the app updates itself from +val updateRepository = (findProperty("desktopUpdateRepo") as String?) ?: "atomic-crafter/cloudstream" + +val generateBuildInfo by tasks.registering { + val output = layout.buildDirectory.dir("generated/buildinfo/kotlin") + val version = appVersion + val repository = updateRepository + inputs.property("version", version) + inputs.property("repository", repository) + outputs.dir(output) + doLast { + val file = output.get().file("com/lagradost/cloudstream4/desktop/BuildInfo.kt").asFile + file.parentFile.mkdirs() + file.writeText( + """ + |package com.lagradost.cloudstream4.desktop + | + |object BuildInfo { + | const val VERSION = "$version" + | const val UPDATE_REPOSITORY = "$repository" + |} + |""".trimMargin() + ) + } +} + +kotlin.sourceSets.getByName("jvmMain").kotlin.srcDir(generateBuildInfo) compose.desktop { application { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 8b9a4061083..a26991c960b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -22,6 +22,7 @@ import com.lagradost.cloudstream4.compose.LocalFocusOutlineDefault import com.lagradost.cloudstream4.desktop.data.AppData import com.lagradost.cloudstream4.desktop.data.AppDirs import com.lagradost.cloudstream4.desktop.ui.App +import com.lagradost.cloudstream4.desktop.update.Updater import com.lagradost.cloudstream4.desktop.ui.LocalWindowState import com.lagradost.cloudstream4.desktop.ui.Navigator import com.lagradost.cloudstream4.desktop.ui.Route @@ -36,6 +37,8 @@ import org.jetbrains.compose.resources.stringResource fun main() { System.setProperty("apple.awt.application.name", "CloudStream") System.setProperty("apple.awt.application.appearance", "system") + // Covers closing the window, Cmd+Q and logging out + Runtime.getRuntime().addShutdownHook(Thread { Updater.installOnExitIfReady() }) application { setSingletonImageLoaderFactory { context -> diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt index a4328797b9d..4a083802537 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt @@ -71,6 +71,9 @@ data class Settings( val seekSeconds: Int = 10, /** Empty means ~/Downloads/CloudStream */ val downloadDirectory: String = "", + val checkForUpdates: Boolean = true, + /** Download updates in the background and install them on restart or quit */ + val autoUpdate: Boolean = true, ) /** diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt index 64666bc10b9..29463c15a46 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt @@ -13,7 +13,9 @@ import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -36,7 +38,10 @@ import com.lagradost.cloudstream4.desktop.generated.resources.home import com.lagradost.cloudstream4.desktop.generated.resources.library import com.lagradost.cloudstream4.desktop.generated.resources.search import com.lagradost.cloudstream4.desktop.generated.resources.settings +import com.lagradost.cloudstream4.desktop.data.AppData import com.lagradost.cloudstream4.desktop.plugins.PluginManager +import com.lagradost.cloudstream4.desktop.update.UpdateState +import com.lagradost.cloudstream4.desktop.update.Updater import kotlinx.coroutines.launch import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource @@ -71,6 +76,24 @@ fun App(navigator: Navigator) { loadingPlugins = false val failed = PluginManager.errors.value.size if (failed > 0) notify("$failed extensions failed to load, see the Extensions tab") + if (AppData.settings.value.checkForUpdates) Updater.check() + } + + // Offer the update once it is downloaded (or found, when automatic updates are off) + LaunchedEffect(Unit) { + Updater.state.collect { update -> + val (message, action, onAction) = when (update) { + is UpdateState.Ready -> Triple("CloudStream ${update.update.version} is ready to install", "Restart", Updater::installAndRestart) + is UpdateState.Available -> Triple("CloudStream ${update.update.version} is available", "Download") { + if (Updater.canInstall) Updater.download(update.update) else openInBrowser(update.update.asset.downloadUrl) + } + else -> return@collect + } + scope.launch { + val result = snackbar.showSnackbar(message, actionLabel = action, duration = SnackbarDuration.Indefinite, withDismissAction = true) + if (result == SnackbarResult.ActionPerformed) onAction() + } + } } val stateHolder = rememberSaveableStateHolder() diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt index cb3254ea8c9..d65b225c286 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt @@ -32,12 +32,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.desktop.BuildInfo import com.lagradost.cloudstream4.desktop.data.AppData import com.lagradost.cloudstream4.desktop.data.AppDirs import com.lagradost.cloudstream4.desktop.data.PlayerKind import com.lagradost.cloudstream4.desktop.downloads.DownloadManager import com.lagradost.cloudstream4.desktop.player.ExternalPlayer import com.lagradost.cloudstream4.desktop.player.Vlc +import com.lagradost.cloudstream4.desktop.update.UpdateState +import com.lagradost.cloudstream4.desktop.update.Updater import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor import com.lagradost.cloudstream4.theme.CloudStreamThemeMode import java.awt.Desktop @@ -152,6 +155,50 @@ fun SettingsScreen() { TextButton(onClick = { runCatching { Desktop.getDesktop().open(AppDirs.dataDir) } }) { Text("Open data folder") } } + Section("Updates") { + val update by Updater.state.collectAsState() + Text("Version ${Updater.currentVersion}, updates from github.com/${BuildInfo.UPDATE_REPOSITORY}", style = MaterialTheme.typography.bodyMedium) + SwitchRow("Check for updates on startup", null, settings.checkForUpdates) { value -> + AppData.updateSettings { it.copy(checkForUpdates = value) } + } + SwitchRow( + "Update automatically", + "Downloads new versions in the background and installs them when you restart or quit", + settings.autoUpdate, + ) { value -> AppData.updateSettings { it.copy(autoUpdate = value) } } + if (!Updater.canInstall) { + Text( + "Running outside the installed app, updates can be checked but not installed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + when (val current = update) { + is UpdateState.Ready -> TextButton(onClick = Updater::installAndRestart) { Text("Restart and update to ${current.update.version}") } + is UpdateState.Available -> TextButton(onClick = { + if (Updater.canInstall) Updater.download(current.update) else openInBrowser(current.update.asset.downloadUrl) + }) { Text("Download ${current.update.version}") } + + is UpdateState.Downloading, UpdateState.Checking -> Unit + else -> TextButton(onClick = { Updater.check(userInitiated = true) }) { Text("Check for updates") } + } + Text( + when (val current = update) { + UpdateState.Idle -> "" + UpdateState.Checking -> "Checking..." + UpdateState.UpToDate -> "You have the latest version" + is UpdateState.Available -> "Version ${current.update.version} is available" + is UpdateState.Downloading -> "Downloading ${current.update.version}: ${(current.progress * 100).toInt()}%" + is UpdateState.Ready -> "Version ${current.update.version} will be installed when you quit" + is UpdateState.Failed -> current.message + }, + style = MaterialTheme.typography.bodySmall, + color = if (update is UpdateState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Section("About") { Text("CloudStream for desktop, running on ${AppDirs.os.name}", style = MaterialTheme.typography.bodyMedium) TextButton(onClick = { openInBrowser("https://github.com/recloudstream/cloudstream") }) { Text("Source code") } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/update/Updater.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/update/Updater.kt new file mode 100644 index 00000000000..939cd6f2c8e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/update/Updater.kt @@ -0,0 +1,266 @@ +package com.lagradost.cloudstream4.desktop.update + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mapper +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream4.desktop.BuildInfo +import com.lagradost.cloudstream4.desktop.data.AppData +import com.lagradost.cloudstream4.desktop.data.AppDirs +import com.lagradost.cloudstream4.desktop.data.OperatingSystem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import kotlin.coroutines.coroutineContext +import kotlin.system.exitProcess + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GithubAsset( + val name: String, + @JsonProperty("browser_download_url") val downloadUrl: String, + val size: Long = 0, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, + val name: String? = null, + val body: String? = null, + val draft: Boolean = false, + val prerelease: Boolean = false, + val assets: List = emptyList(), +) + +data class Update(val version: String, val notes: String?, val asset: GithubAsset) + +sealed interface UpdateState { + data object Idle : UpdateState + data object Checking : UpdateState + data object UpToDate : UpdateState + data class Available(val update: Update) : UpdateState + data class Downloading(val update: Update, val progress: Float) : UpdateState + /** Downloaded, installed on restart or when the app quits */ + data class Ready(val update: Update, val file: File) : UpdateState + data class Failed(val message: String) : UpdateState +} + +/** + * Updates the installed app from the GitHub releases of [BuildInfo.UPDATE_REPOSITORY]. + * Releases are the `desktop-v*` tags published by the Desktop Installers workflow. + */ +object Updater { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private const val TAG_PREFIX = "desktop-v" + + private val _state = MutableStateFlow(UpdateState.Idle) + val state: StateFlow = _state.asStateFlow() + + val currentVersion: String get() = BuildInfo.VERSION + + /** Path of the running launcher, set by jpackage. Null when running from Gradle, where updating makes no sense */ + private val appPath: String? get() = System.getProperty("jpackage.app-path")?.takeIf { it.isNotBlank() } + + val canInstall: Boolean get() = appPath != null + + private fun parseVersion(version: String): List = + version.removePrefix("v").split('.', '-').mapNotNull { it.toIntOrNull() } + + /** Positive if [a] is newer than [b] */ + fun compareVersions(a: String, b: String): Int { + val left = parseVersion(a) + val right = parseVersion(b) + for (i in 0 until maxOf(left.size, right.size)) { + val diff = left.getOrElse(i) { 0 } - right.getOrElse(i) { 0 } + if (diff != 0) return diff + } + return 0 + } + + /** Release assets are named like CloudStream-macos-arm64-CloudStream-1.2.3.dmg by the workflow */ + private fun pickAsset(assets: List): GithubAsset? { + val arm = System.getProperty("os.arch").lowercase().let { it.contains("aarch64") || it.contains("arm64") } + val (platform, extension) = when (AppDirs.os) { + OperatingSystem.MacOS -> (if (arm) "macos-arm64" else "macos-x64") to ".dmg" + OperatingSystem.Windows -> "windows-x64" to ".msi" + OperatingSystem.Linux -> "linux-x64" to ".deb" + } + return assets.firstOrNull { it.name.contains(platform) && it.name.endsWith(extension) } + } + + suspend fun fetchLatest(): Update? { + val text = app.get( + "https://api.github.com/repos/${BuildInfo.UPDATE_REPOSITORY}/releases?per_page=20", + headers = mapOf("Accept" to "application/vnd.github+json"), + ).text + return mapper.readValue>(text) + .filter { !it.draft && !it.prerelease && it.tagName.startsWith(TAG_PREFIX) } + .mapNotNull { release -> + pickAsset(release.assets)?.let { Update(release.tagName.removePrefix(TAG_PREFIX), release.body, it) } + } + .maxWithOrNull { a, b -> compareVersions(a.version, b.version) } + ?.takeIf { compareVersions(it.version, currentVersion) > 0 } + } + + /** Checks for an update and, if automatic updates are on, downloads it in the background */ + fun check(userInitiated: Boolean = false) { + val current = _state.value + if (current is UpdateState.Checking || current is UpdateState.Downloading || current is UpdateState.Ready) return + _state.value = UpdateState.Checking + scope.launch { + val update = try { + fetchLatest() + } catch (t: Throwable) { + logError(t) + _state.value = if (userInitiated) UpdateState.Failed("Could not check for updates: ${t.message}") else UpdateState.Idle + return@launch + } + when { + update == null -> _state.value = UpdateState.UpToDate + AppData.settings.value.autoUpdate && canInstall -> download(update) + else -> _state.value = UpdateState.Available(update) + } + } + } + + fun download(update: Update) { + _state.value = UpdateState.Downloading(update, 0f) + scope.launch { + try { + val dir = File(AppDirs.cacheDir, "updates").also { it.deleteRecursively(); it.mkdirs() } + val target = File(dir, update.asset.name) + val part = File(dir, update.asset.name + ".part") + app.get(update.asset.downloadUrl).okhttpResponse.use { response -> + if (!response.isSuccessful) throw IllegalStateException("HTTP ${response.code}") + val total = response.body.contentLength().takeIf { it > 0 } ?: update.asset.size + var written = 0L + part.outputStream().use { out -> + response.body.byteStream().use { input -> + val buffer = ByteArray(256 * 1024) + while (true) { + coroutineContext.ensureActive() + val read = input.read(buffer) + if (read == -1) break + out.write(buffer, 0, read) + written += read + if (total > 0) _state.value = UpdateState.Downloading(update, written.toFloat() / total) + } + } + } + if (total > 0 && written != total) throw IllegalStateException("Download incomplete") + } + part.renameTo(target) + _state.value = UpdateState.Ready(update, target) + } catch (t: Throwable) { + logError(t) + _state.value = UpdateState.Failed("Update download failed: ${t.message}") + } + } + } + + @Volatile + private var installing = false + + /** Installs a downloaded update when the app quits, so automatic updates need no clicks at all */ + fun installOnExitIfReady() { + if (installing) return + val ready = _state.value as? UpdateState.Ready ?: return + if (AppData.settings.value.autoUpdate) runCatching { launchInstaller(ready.file, relaunch = false) }.onFailure { logError(it) } + } + + /** Quits, installs the downloaded update and starts the new version */ + fun installAndRestart() { + val ready = _state.value as? UpdateState.Ready ?: return + try { + launchInstaller(ready.file, relaunch = true) + installing = true + exitProcess(0) + } catch (t: Throwable) { + logError(t) + _state.value = UpdateState.Failed("Could not install the update: ${t.message}") + } + } + + /** + * The running app cannot replace itself, so a small script waits for this process to exit, + * installs the update and optionally starts the new version. + */ + private fun launchInstaller(file: File, relaunch: Boolean) { + val launcher = appPath ?: throw IllegalStateException("Updates only work in the installed app") + val pid = ProcessHandle.current().pid().toString() + val flag = if (relaunch) "1" else "0" + val dir = file.parentFile + + val command = when (AppDirs.os) { + OperatingSystem.MacOS -> { + // .../CloudStream.app/Contents/MacOS/CloudStream -> .../CloudStream.app + val bundle = File(launcher).parentFile.parentFile.parentFile.absolutePath + val script = File(dir, "install.sh").apply { + writeText( + """ + |#!/bin/sh + |while kill -0 "$1" 2>/dev/null; do sleep 0.5; done + |MNT=$(mktemp -d) + |# The DMG shows the license first, answer it so this runs unattended + |yes | hdiutil attach -nobrowse -readonly -noverify -mountpoint "${'$'}MNT" "$2" >/dev/null || exit 1 + |NEW=$(find "${'$'}MNT" -maxdepth 1 -name "*.app" | head -n 1) + |rm -rf "$3.old" + |if mv "$3" "$3.old" && ditto "${'$'}NEW" "$3"; then + | rm -rf "$3.old" + |else + | rm -rf "$3"; mv "$3.old" "$3" + |fi + |hdiutil detach "${'$'}MNT" -quiet + |xattr -dr com.apple.quarantine "$3" 2>/dev/null + |if [ "$4" = "1" ]; then open "$3"; fi + |""".trimMargin() + ) + setExecutable(true) + } + listOf("/bin/sh", script.absolutePath, pid, file.absolutePath, bundle, flag) + } + + OperatingSystem.Windows -> { + val script = File(dir, "install.cmd").apply { + writeText( + """ + |@echo off + |:wait + |tasklist /FI "PID eq %1" 2>nul | find "%1" >nul && (timeout /t 1 /nobreak >nul & goto wait) + |msiexec /i "%~2" /passive /norestart + |if "%~4"=="1" start "" "%~3" + |""".trimMargin().replace("\n", "\r\n") + ) + } + listOf("cmd", "/c", script.absolutePath, pid, file.absolutePath, launcher, flag) + } + + OperatingSystem.Linux -> { + val script = File(dir, "install.sh").apply { + writeText( + """ + |#!/bin/sh + |while kill -0 "$1" 2>/dev/null; do sleep 0.5; done + |# Installing a .deb needs root, pkexec shows the system password prompt + |pkexec dpkg -i "$2" || xdg-open "$2" + |if [ "$4" = "1" ]; then "$3" & fi + |""".trimMargin() + ) + setExecutable(true) + } + listOf("/bin/sh", script.absolutePath, pid, file.absolutePath, launcher, flag) + } + } + ProcessBuilder(command) + .redirectOutput(File(dir, "install.log")) + .redirectErrorStream(true) + .start() + } +} From d0e464a81e59470015c1e96e7834e362668736f9 Mon Sep 17 00:00:00 2001 From: atomic-crafter <64207317+atomic-crafter@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:29:34 +0100 Subject: [PATCH 3/5] Desktop: bundle jdk.management so StreamPlay's memory check works in the installed app The packaged runtime only contains the listed JDK modules, so com.sun.management was missing there while it worked when running from Gradle. Also fall back to the JVM's own memory numbers if the module is ever unavailable. --- desktopApp/build.gradle.kts | 1 + .../jvmMain/kotlin/android/app/ActivityManager.kt | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 50dc1054415..76afe66e4fc 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -131,6 +131,7 @@ compose.desktop { "jdk.dynalink", "jdk.httpserver", "jdk.localedata", + "jdk.management", "jdk.net", "jdk.unsupported", "jdk.zipfs", diff --git a/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt b/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt index eb6bee406a4..a113961141c 100644 --- a/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt +++ b/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt @@ -12,9 +12,15 @@ open class ActivityManager { } open fun getMemoryInfo(outInfo: MemoryInfo) { - val os = ManagementFactory.getOperatingSystemMXBean() as? com.sun.management.OperatingSystemMXBean - outInfo.totalMem = os?.totalMemorySize ?: Runtime.getRuntime().maxMemory() - outInfo.availMem = os?.freeMemorySize ?: Runtime.getRuntime().freeMemory() + // com.sun.management lives in the jdk.management module, fall back to the JVM's own numbers without it + val (total, free) = try { + val os = ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean + os.totalMemorySize to os.freeMemorySize + } catch (_: Throwable) { + Runtime.getRuntime().maxMemory() to Runtime.getRuntime().freeMemory() + } + outInfo.totalMem = total + outInfo.availMem = free outInfo.threshold = outInfo.totalMem / 20 outInfo.lowMemory = outInfo.availMem < outInfo.threshold } From a4773cde440df588269e54544b9b86019baaaecf Mon Sep 17 00:00:00 2001 From: atomic-crafter <64207317+atomic-crafter@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:49:48 +0100 Subject: [PATCH 4/5] Desktop: sync provider, Base64 and Uri shims so StreamPlay loads --- .../src/jvmMain/kotlin/android/net/Uri.kt | 50 +++++++++++++ .../src/jvmMain/kotlin/android/util/Base64.kt | 41 +++++++++++ .../syncproviders/AccountManager.kt | 53 ++++++++++++++ .../syncproviders/providers/AniListApi.kt | 70 +++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/android/net/Uri.kt create mode 100644 desktopApp/src/jvmMain/kotlin/android/util/Base64.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt diff --git a/desktopApp/src/jvmMain/kotlin/android/net/Uri.kt b/desktopApp/src/jvmMain/kotlin/android/net/Uri.kt new file mode 100644 index 00000000000..e9735b3e7a6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/net/Uri.kt @@ -0,0 +1,50 @@ +package android.net + +import java.net.URI +import java.net.URLDecoder + +/** The parts of android.net.Uri extensions use, backed by java.net.URI */ +open class Uri private constructor(private val raw: String) { + private val uri: URI? = runCatching { URI(raw) }.getOrNull() + + open fun getScheme(): String? = uri?.scheme + open fun getHost(): String? = uri?.host + open fun getPort(): Int = uri?.port ?: -1 + open fun getAuthority(): String? = uri?.authority + open fun getPath(): String? = uri?.path + open fun getEncodedPath(): String? = uri?.rawPath + open fun getQuery(): String? = uri?.query + open fun getEncodedQuery(): String? = uri?.rawQuery + open fun getFragment(): String? = uri?.fragment + open fun getPathSegments(): List = getPath()?.split('/')?.filter { it.isNotEmpty() } ?: emptyList() + open fun getLastPathSegment(): String? = getPathSegments().lastOrNull() + + private fun queryPairs(): List> = + getEncodedQuery()?.split('&')?.filter { it.isNotEmpty() }?.map { + val key = it.substringBefore('=') + val value = if ('=' in it) it.substringAfter('=') else "" + URLDecoder.decode(key, "UTF-8") to URLDecoder.decode(value, "UTF-8") + } ?: emptyList() + + 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 } + + override fun toString(): String = raw + override fun equals(other: Any?): Boolean = other is Uri && other.raw == raw + override fun hashCode(): Int = raw.hashCode() + + companion object { + @JvmField + val EMPTY = Uri("") + + @JvmStatic + fun parse(uriString: String): Uri = Uri(uriString) + + @JvmStatic + fun encode(s: String?): String? = s?.let { java.net.URLEncoder.encode(it, "UTF-8").replace("+", "%20") } + + @JvmStatic + fun decode(s: String?): String? = s?.let { URLDecoder.decode(it.replace("+", "%2B"), "UTF-8") } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/android/util/Base64.kt b/desktopApp/src/jvmMain/kotlin/android/util/Base64.kt new file mode 100644 index 00000000000..144ed7827a7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/util/Base64.kt @@ -0,0 +1,41 @@ +package android.util + +/** android.util.Base64 on top of java.util.Base64, same flags and signatures */ +object Base64 { + 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 + + @JvmStatic + fun decode(str: String, flags: Int): ByteArray = decode(str.toByteArray(Charsets.US_ASCII), flags) + + @JvmStatic + fun decode(input: ByteArray, flags: Int): ByteArray { + // Android ignores whitespace and accepts both alphabets and missing padding + val clean = String(input, Charsets.US_ASCII).filterNot { it.isWhitespace() } + .replace('-', '+').replace('_', '/').trimEnd('=') + val padded = clean + "=".repeat((4 - clean.length % 4) % 4) + return try { + java.util.Base64.getDecoder().decode(padded) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("bad base-64", e) + } + } + + @JvmStatic + fun encode(input: ByteArray, flags: Int): ByteArray = encodeToString(input, flags).toByteArray(Charsets.US_ASCII) + + @JvmStatic + fun encodeToString(input: ByteArray, flags: Int): String { + 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) + if (flags and NO_WRAP != 0) return text + // Android wraps at 76 characters and ends with a newline unless NO_WRAP is set + val newline = if (flags and CRLF != 0) "\r\n" else "\n" + return text.chunked(76).joinToString(newline, postfix = newline) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt new file mode 100644 index 00000000000..bc1cbd8e8aa --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -0,0 +1,53 @@ +package com.lagradost.cloudstream3.syncproviders + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi + +/** + * Account syncing (AniList, MAL, ...) is not available on desktop yet. These keep the same + * binary signatures as the app so extensions that reference them still load; nobody is ever + * logged in, which extensions already handle as "login required". + */ +class AccountManager { + companion object { + val aniListApi = AniListApi() + } +} + +data class AuthUser( + @JsonProperty("name") val name: String?, + @JsonProperty("id") val id: Int, + @JsonProperty("profilePicture") val profilePicture: String? = null, + @JsonProperty("profilePictureHeaders") val profilePictureHeaders: Map? = null, +) + +abstract class SyncAPI { + open val name: String = "NONE" + open val idPrefix: String = "NONE" + open val mainUrl: String = "NONE" + open var requireLibraryRefresh: Boolean = true + open val syncIdName: SyncIdName? = null + + data class LibraryMetadata( + val allLibraryLists: List, + val supportedListSorting: Set, + ) + + data class LibraryList( + // UiText in the app, only read after a login, which cannot happen here + val name: Any, + val items: List, + ) +} + +class SyncRepo(val api: SyncAPI) { + val syncIdName = api.syncIdName + val name get() = api.name + val idPrefix get() = api.idPrefix + + fun authUser(): AuthUser? = null + + // Kotlin mangles functions returning Result, extensions call it by this name + @JvmName("library_IoAF18A") + suspend fun library(): Any = Result.success(null) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt new file mode 100644 index 00000000000..a45ee9a764e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -0,0 +1,70 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName + +/** Signature-compatible stand-in for the app's AniList sync, keeps the response models extensions reuse */ +class AniListApi : SyncAPI() { + override val name = "AniList" + override val idPrefix = "anilist" + override val mainUrl = "https://anilist.co" + override val syncIdName = SyncIdName.Anilist + + data class Title( + @JsonProperty("english") val english: String?, + @JsonProperty("romaji") val romaji: String?, + ) + + data class CoverImage( + @JsonProperty("medium") val medium: String?, + @JsonProperty("large") val large: String?, + @JsonProperty("extraLarge") val extraLarge: String?, + ) + + data class LikePageInfo( + @JsonProperty("total") val total: Int?, + @JsonProperty("currentPage") val currentPage: Int?, + @JsonProperty("lastPage") val lastPage: Int?, + @JsonProperty("perPage") val perPage: Int?, + @JsonProperty("hasNextPage") val hasNextPage: Boolean?, + ) + + data class MediaTitle( + @JsonProperty("romaji") val romaji: String?, + @JsonProperty("english") val english: String?, + @JsonProperty("native") val native: String?, + @JsonProperty("userPreferred") val userPreferred: String?, + ) + + data class MediaCoverImage( + @JsonProperty("extraLarge") val extraLarge: String?, + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, + @JsonProperty("color") val color: String?, + ) + + data class SeasonNextAiringEpisode( + @JsonProperty("episode") val episode: Int?, + @JsonProperty("timeUntilAiring") val timeUntilAiring: Int?, + ) + + data class RecommendedMedia( + @JsonProperty("id") val id: Int?, + @JsonProperty("title") val title: MediaTitle?, + @JsonProperty("coverImage") val coverImage: MediaCoverImage?, + ) + + data class RecommendationConnection( + @JsonProperty("edges") val edges: List = emptyList(), + @JsonProperty("nodes") val nodes: List = emptyList(), + ) + + data class RecommendationEdge( + @JsonProperty("node") val node: Recommendation, + ) + + data class Recommendation( + @JsonProperty("mediaRecommendation") val mediaRecommendation: RecommendedMedia?, + ) +} From 551f16fad65043327f1756f0a4b940386858af3b Mon Sep 17 00:00:00 2001 From: atomic-crafter <64207317+atomic-crafter@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:59:41 +0100 Subject: [PATCH 5/5] Desktop: keep Kotlin method names when converting extensions, start VLC off the UI thread --- .../syncproviders/AccountManager.kt | 4 ++-- .../cloudstream4/desktop/player/VlcEngine.kt | 18 +++++++++++---- .../desktop/plugins/DexConverter.kt | 7 +++++- .../desktop/plugins/PluginManager.kt | 11 ++++++++++ .../cloudstream4/desktop/ui/PlayerScreen.kt | 22 ++++++++++++++----- .../cloudstream4/desktop/ui/SettingsScreen.kt | 21 ++++++++++++------ 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index bc1cbd8e8aa..256c4a0f46a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -47,7 +47,7 @@ class SyncRepo(val api: SyncAPI) { fun authUser(): AuthUser? = null - // Kotlin mangles functions returning Result, extensions call it by this name - @JvmName("library_IoAF18A") + // Kotlin mangles functions returning Result, extensions call it by the app's mangled name + @JvmName("library-IoAF18A") suspend fun library(): Any = Result.success(null) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt index 1e49bb33a14..90a2f9a8454 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt @@ -26,11 +26,16 @@ import javax.swing.SwingUtilities data class Track(val id: Int, val name: String) object Vlc { - /** Null if LibVLC (the VLC app) is not installed */ - val factory: MediaPlayerFactory? by lazy { + /** Only finds the LibVLC libraries, without starting VLC itself, so screens can check it cheaply */ + val isInstalled: Boolean by lazy { + runCatching { NativeDiscovery().discover() }.onFailure { logError(it) }.getOrDefault(false) + } + + /** Null if LibVLC (the VLC app) is not installed. Starting it loads every VLC plugin, so only the player uses it */ + private val factoryLazy = lazy { try { - if (NativeDiscovery().discover()) { - MediaPlayerFactory("--no-video-title-show", "--quiet", "--network-caching=3000") + if (isInstalled) { + MediaPlayerFactory("--no-video-title-show", "--quiet", "--network-caching=3000", "--no-metadata-network-access") } else null } catch (t: Throwable) { logError(t) @@ -38,6 +43,11 @@ object Vlc { } } + val factory: MediaPlayerFactory? by factoryLazy + + /** If [factory] was already created, reading it is then instant */ + val isStarted: Boolean get() = factoryLazy.isInitialized() + val isAvailable: Boolean get() = factory != null } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt index e2a78abccb8..188f3830569 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt @@ -18,7 +18,10 @@ import java.util.zip.ZipOutputStream */ object DexConverter { /** Bump when the conversion changes, older conversions are then repaired on startup */ - const val VERSION = 1 + const val VERSION = 2 + + /** Conversions older than this cannot be repaired in place and are converted again from the original */ + const val RECONVERT_BELOW = 2 /** Applies the bytecode fixes of the current [VERSION] to a jar converted by an older version */ fun repairJar(jar: File) { @@ -103,6 +106,8 @@ object DexConverter { .topoLogicalSort() .skipDebug(false) .optimizeSynchronized(true) + // Kotlin names like `constructor-impl` are valid on the JVM, renaming them breaks calls into Kotlin and the app + .dontSanitizeNames(true) .to(classesDir.toPath()) } else if (entry.name == "manifest.json" || !entry.name.startsWith("res/")) { // Keep the manifest and any plain java resources, Android resources are useless here diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt index 3db17fffb5c..72a981a00e8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt @@ -264,6 +264,9 @@ object PluginManager { AppData.plugins.value.forEach { plugin -> if (!File(plugin.filePath).exists()) { AppData.removePlugin(plugin.filePath) + } else if (plugin.convertedFromDex && plugin.converterVersion < DexConverter.RECONVERT_BELOW) { + // Falls back to the old conversion if the repository cannot be reached + if (reinstall(plugin).onFailure { logError(it) }.isFailure) load(plugin) } else if (plugin.convertedFromDex && plugin.converterVersion < DexConverter.VERSION) { val repaired = runCatching { DexConverter.repairJar(File(plugin.filePath)) } .onFailure { logError(it) }.isSuccess @@ -276,6 +279,14 @@ object PluginManager { } } + /** Downloads and converts an installed extension again, from the latest version in its repository */ + private suspend fun reinstall(plugin: InstalledPlugin): Result = runCatching { + val repository = fetchRepository(plugin.repositoryUrl) ?: throw IllegalStateException("Repository unavailable") + val remote = fetchRepositoryPlugins(repository).firstOrNull { it.internalName == plugin.internalName } + ?: throw IllegalStateException("${plugin.name} is no longer in its repository") + install(plugin.repositoryUrl, remote).getOrThrow() + } + /** Checks every repository for newer versions of the installed extensions and installs them */ suspend fun updateAll(): Int { var updated = 0 diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt index 5f7efe7bab4..19ee8fdf8f5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -86,15 +87,17 @@ import com.lagradost.cloudstream4.desktop.generated.resources.volume_up import com.lagradost.cloudstream4.desktop.player.ExternalPlayer import com.lagradost.cloudstream4.desktop.player.Vlc import com.lagradost.cloudstream4.desktop.player.VlcEngine -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.DrawableResource -import org.jetbrains.compose.resources.painterResource import java.awt.Cursor import java.awt.Point import java.awt.Toolkit import java.awt.image.BufferedImage import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource val LocalWindowState = compositionLocalOf { null } @@ -127,7 +130,16 @@ fun PlayerScreen(route: Route.Player) { val hasNext = route.index + 1 < route.playlist.size val playNext = { navigator.replace(route.copy(index = route.index + 1)) } - if (settings.player == PlayerKind.External || !Vlc.isAvailable) { + // Starting VLC takes a moment the first time, do it off the UI thread + val vlcAvailable by produceState(if (Vlc.isStarted) Vlc.isAvailable else null) { + if (value == null) value = withContext(Dispatchers.IO) { Vlc.isAvailable } + } + if (settings.player == PlayerKind.Internal && vlcAvailable == null) { + Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return + } + + if (settings.player == PlayerKind.External || vlcAvailable == false) { ExternalPlayerScreen(route, viewModel, state, hasNext, playNext) return } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt index d65b225c286..5dbf51816a9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt @@ -26,6 +26,9 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import androidx.compose.runtime.produceState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -107,13 +110,17 @@ fun SettingsScreen() { FilterChip(settings.player == PlayerKind.External, { AppData.updateSettings { it.copy(player = PlayerKind.External) } }, label = { Text("External player") }) } - Text( - if (Vlc.isAvailable) "VLC found, the built-in player is available." - else "VLC was not found. Install VLC to use the built-in player.", - style = MaterialTheme.typography.bodySmall, - color = if (Vlc.isAvailable) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, - ) - if (!Vlc.isAvailable) TextButton(onClick = { openInBrowser("https://www.videolan.org/vlc/") }) { Text("Download VLC") } + // Looked up off the UI thread, null until known + val vlcInstalled by produceState(null) { value = withContext(Dispatchers.IO) { Vlc.isInstalled } } + vlcInstalled?.let { installed -> + Text( + if (installed) "VLC found, the built-in player is available." + else "VLC was not found. Install VLC to use the built-in player.", + style = MaterialTheme.typography.bodySmall, + color = if (installed) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + ) + } + if (vlcInstalled == false) TextButton(onClick = { openInBrowser("https://www.videolan.org/vlc/") }) { Text("Download VLC") } OutlinedTextField( value = settings.externalPlayer, onValueChange = { value -> AppData.updateSettings { it.copy(externalPlayer = value) } },