diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000000..acf60104979 --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,121 @@ +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, desktop-port] + 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 + # 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 + 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. + 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 539c51d36f5..76afe66e4fc 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,140 @@ 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 +} + +// 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 { 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")) + + // 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.management", + "jdk.net", + "jdk.unsupported", + "jdk.zipfs", + ) - val iconsRoot = project.file("desktop-icons") + val iconsRoot = project.file("src/desktop-icons") macOS { - // iconFile.set(iconsRoot.resolve("icon-mac.icns")) + 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 394459001fd..d959673a73e 100644 Binary files a/desktopApp/src/desktop-icons/icon-linux.png and b/desktopApp/src/desktop-icons/icon-linux.png differ diff --git a/desktopApp/src/desktop-icons/icon-mac.icns b/desktopApp/src/desktop-icons/icon-mac.icns new file mode 100644 index 00000000000..b4fe1192696 Binary files /dev/null and b/desktopApp/src/desktop-icons/icon-mac.icns differ 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..a113961141c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/android/app/ActivityManager.kt @@ -0,0 +1,29 @@ +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) { + // 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 + } + + 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/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/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/syncproviders/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt new file mode 100644 index 00000000000..256c4a0f46a --- /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 the app's mangled 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?, + ) +} 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..a26991c960b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -1,62 +1,83 @@ 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.update.Updater +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") + // Covers closing the window, Cmd+Q and logging out + Runtime.getRuntime().addShutdownHook(Thread { Updater.installOnExitIfReady() }) + + 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..4a083802537 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/data/AppData.kt @@ -0,0 +1,154 @@ +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 = "", + val checkForUpdates: Boolean = true, + /** Download updates in the background and install them on restart or quit */ + val autoUpdate: Boolean = true, +) + +/** + * 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..90a2f9a8454 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/player/VlcEngine.kt @@ -0,0 +1,274 @@ +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 { + /** 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 (isInstalled) { + MediaPlayerFactory("--no-video-title-show", "--quiet", "--network-caching=3000", "--no-metadata-network-access") + } else null + } catch (t: Throwable) { + logError(t) + null + } + } + + 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 +} + +/** + * 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..188f3830569 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/DexConverter.kt @@ -0,0 +1,135 @@ +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 = 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) { + 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) + // 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 + 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..72a981a00e8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/plugins/PluginManager.kt @@ -0,0 +1,388 @@ +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.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 + val updated = if (repaired) plugin.copy(converterVersion = DexConverter.VERSION) else plugin + if (repaired) AppData.putPlugin(updated) + load(updated) + } else { + load(plugin) + } + } + } + + /** 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 + 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..29463c15a46 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/App.kt @@ -0,0 +1,149 @@ +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.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 +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.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 + +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") + 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() + 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..19ee8fdf8f5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/PlayerScreen.kt @@ -0,0 +1,485 @@ +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.produceState +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 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 } + +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)) } + + // 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 + } + + 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..5dbf51816a9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/desktop/ui/SettingsScreen.kt @@ -0,0 +1,215 @@ +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 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 +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 + +@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") }) + } + // 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) } }, + 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("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") } + } + Spacer(Modifier.height(32.dp)) + } +} 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() + } +} 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" }