From be20cf673289d412ed6832f3c8392b4ddb6dde41 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Thu, 1 Oct 2026 23:19:47 +0530 Subject: [PATCH 01/64] Desktop: build a working Windows MSI and add a Windows CI job - Fix the icon path (icons live in src/desktop-icons) - Take packageVersion from versionName instead of a hardcoded 1.0.0 - Set a fixed upgradeUuid so new versions upgrade in place - Add Start menu entry, desktop shortcut and install folder picker - Bundle the JDK modules the app needs (java.net.http etc.) - New desktop.yml workflow: runs library jvmTest and packageMsi on windows-latest, uploads the MSI Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/desktop.yml | 53 +++++++++++++++++++++++++++++++++++ desktopApp/build.gradle.kts | 16 ++++++++--- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/desktop.yml diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000000..bef85f24399 --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,53 @@ +name: Desktop Build + +on: + pull_request: + push: + branches: [ master ] + paths-ignore: + - '*.md' + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: ${{ github.event_name == 'pull_request' }} + + - name: Run library tests + run: ./gradlew :library:jvmTest + + - name: Build MSI + run: ./gradlew :desktopApp:packageMsi + + - name: Upload MSI + uses: actions/upload-artifact@v7 + with: + name: desktop-windows-msi + path: desktopApp/build/compose/binaries/main/msi/*.msi + if-no-files-found: error + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: desktop-test-reports + path: library/build/reports/tests/jvmTest/ diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 539c51d36f5..1cae80b1051 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -35,17 +35,25 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "CloudStream" - packageVersion = "1.0.0" + // MSI requires MAJOR.MINOR.BUILD with numbers only, which versionName already is + packageVersion = libs.versions.versionName.get() - val iconsRoot = project.file("desktop-icons") + // The packaged runtime only contains these JDK modules, regenerate with ./gradlew :desktopApp:suggestRuntimeModules + modules("java.instrument", "java.management", "java.net.http", "java.sql", "jdk.dynalink", "jdk.unsupported") + + val iconsRoot = project.file("src/desktop-icons") macOS { // iconFile.set(iconsRoot.resolve("icon-mac.icns")) } windows { iconFile.set(iconsRoot.resolve("icon-windows.ico")) - // menuGroup = "Compose Examples" + menu = true + menuGroup = "CloudStream" + shortcut = true + dirChooser = true + // Must never change, otherwise new versions install side by side instead of upgrading // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html - // upgradeUuid = "" + upgradeUuid = "33b76f91-08d2-41b1-9ed1-ddde419f24be" } linux { iconFile.set(iconsRoot.resolve("icon-linux.png")) From 3fe7bd629ea19230c0595af28d7742b5e8c9215d Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Thu, 1 Oct 2026 23:53:38 +0530 Subject: [PATCH 02/64] Desktop: save settings, log to a file and remember the window - JvmPreferenceStore keeps every preference in one JSON file under %APPDATA%\CloudStream (or ~/.config, ~/Library/Application Support), written atomically; a corrupt file falls back to defaults and is kept as settings.json.corrupt - AppDirs resolves the data folder, overridable with -Dcloudstream.home - DesktopLog copies stdout/stderr into logs/cloudstream.log with timestamps and keeps the previous run as cloudstream.old.log - SavedWindowState restores window size, position and maximized state, ignoring positions on monitors that are no longer connected - Unit tests for the preference store, run in the desktop CI job Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/desktop.yml | 8 +- .../com/lagradost/cloudstream4/DesktopLog.kt | 88 ++++++++ .../kotlin/com/lagradost/cloudstream4/Main.kt | 78 ++++--- .../cloudstream4/SavedWindowState.kt | 78 +++++++ shared/build.gradle.kts | 6 + .../com/lagradost/cloudstream4/AppDirs.kt | 31 +++ .../cloudstream4/DevicePreferenceStore.jvm.kt | 14 +- .../common/preference/JvmPreferenceStore.kt | 206 ++++++++++++++++++ .../preference/JvmPreferenceStoreTest.kt | 130 +++++++++++ 9 files changed, 598 insertions(+), 41 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt create mode 100644 shared/src/jvmMain/kotlin/com/mihon/common/preference/JvmPreferenceStore.kt create mode 100644 shared/src/jvmTest/kotlin/com/mihon/common/preference/JvmPreferenceStoreTest.kt diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index bef85f24399..654deeb7bb4 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -32,8 +32,8 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} cache-read-only: ${{ github.event_name == 'pull_request' }} - - name: Run library tests - run: ./gradlew :library:jvmTest + - name: Run tests + run: ./gradlew :library:jvmTest :shared:jvmTest - name: Build MSI run: ./gradlew :desktopApp:packageMsi @@ -50,4 +50,6 @@ jobs: uses: actions/upload-artifact@v7 with: name: desktop-test-reports - path: library/build/reports/tests/jvmTest/ + path: | + library/build/reports/tests/jvmTest/ + shared/build/reports/tests/jvmTest/ diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt new file mode 100644 index 00000000000..c4336c817d9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt @@ -0,0 +1,88 @@ +package com.lagradost.cloudstream4 + +import java.io.FileOutputStream +import java.io.OutputStream +import java.io.PrintStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.io.path.createDirectories +import kotlin.io.path.exists + +/** + * Copies everything written to stdout and stderr into logs/cloudstream.log, so the installed app, + * which has no console, still leaves a log behind. The log from the previous run is kept as + * cloudstream.old.log. + */ +object DesktopLog { + fun install(dir: Path) { + try { + dir.createDirectories() + val file = dir.resolve("cloudstream.log") + if (file.exists()) Files.move(file, dir.resolve("cloudstream.old.log"), REPLACE_EXISTING) + + val log = TimestampedOutputStream(FileOutputStream(file.toFile())) + System.setOut(PrintStream(TeeOutputStream(System.out, log), true, Charsets.UTF_8)) + System.setErr(PrintStream(TeeOutputStream(System.err, log), true, Charsets.UTF_8)) + } catch (t: Throwable) { + // Logging must never stop the app from starting + System.err.println("Could not create log file in $dir: $t") + } + + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + System.err.println("FATAL Uncaught exception in thread ${thread.name}") + throwable.printStackTrace() + previous?.uncaughtException(thread, throwable) + } + + println( + "INFO DesktopLog: CloudStream ${System.getProperty("jpackage.app-version") ?: "dev"} starting, " + + "Java ${System.getProperty("java.version")}, " + + "${System.getProperty("os.name")} ${System.getProperty("os.version")}" + ) + } + + private class TeeOutputStream(private val a: OutputStream, private val b: OutputStream) : OutputStream() { + override fun write(b: Int) { + a.write(b) + this.b.write(b) + } + + override fun write(bytes: ByteArray, off: Int, len: Int) { + a.write(bytes, off, len) + b.write(bytes, off, len) + } + + override fun flush() { + a.flush() + b.flush() + } + } + + /** Adds a timestamp to the start of every line. Shared by stdout and stderr, so it is synchronized */ + private class TimestampedOutputStream(private val out: OutputStream) : OutputStream() { + private val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS ") + private var lineStart = true + + @Synchronized + override fun write(b: Int) { + if (lineStart) { + out.write(LocalDateTime.now().format(format).toByteArray()) + lineStart = false + } + out.write(b) + if (b == '\n'.code) lineStart = true + } + + @Synchronized + override fun write(bytes: ByteArray, off: Int, len: Int) { + for (i in off until off + len) write(bytes[i].toInt()) + } + + @Synchronized + override fun flush() = out.flush() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 4ff13fe801c..8c0acdaf3a7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -5,11 +5,11 @@ 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.Composable 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.window.Window import androidx.compose.ui.window.application import com.lagradost.cloudstream4.compose.BlackButton @@ -21,42 +21,58 @@ 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.common.preference.toggle import com.mihon.presentation.settings.widget.SwitchPreferenceWidget 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") { - } +fun main() { + DesktopLog.install(AppDirs.logs) + application { + val savedWindow = remember { SavedWindowState(desktopPreferences) } + val windowState = rememberSavedWindowState(savedWindow) + Window( + onCloseRequest = { + savedWindow.save(windowState) + exitApplication() + }, + state = windowState, + title = stringResource(Res.string.app_name), + icon = painterResource(Res.drawable.default_icon) + ) { + MainContent() + } + } +} + +@Composable +private fun MainContent() { + val startPaused = rememberAppSettings().player.startPaused + 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 } - ) } + // Placeholder until the settings screens are connected, this one is saved to disk + val checked by startPaused.changes().collectAsState(startPaused.get()) + SwitchPreferenceWidget( + title = "Start videos paused", subtitle = "Saved between restarts", icon = painterResource( + Res.drawable.preview + ), + checked = checked, + onCheckedChanged = { startPaused.toggle() } + ) } } - } } -} \ No newline at end of file +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt new file mode 100644 index 00000000000..6a56abbdea6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt @@ -0,0 +1,78 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import com.mihon.common.preference.PreferenceData.Companion.appStateKey +import com.mihon.common.preference.PreferenceStore +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import java.awt.GraphicsEnvironment +import java.awt.Rectangle + +/** Window size, position and maximized state, kept as app state so it is left out of backups */ +class SavedWindowState(preferences: PreferenceStore) { + private val x = preferences.getFloat(appStateKey("window_x"), Float.NaN) + private val y = preferences.getFloat(appStateKey("window_y"), Float.NaN) + private val width = preferences.getFloat(appStateKey("window_width"), 1280f) + private val height = preferences.getFloat(appStateKey("window_height"), 800f) + private val maximized = preferences.getBoolean(appStateKey("window_maximized"), false) + + fun toWindowState(): WindowState { + val size = DpSize(width.get().coerceAtLeast(MIN_SIZE).dp, height.get().coerceAtLeast(MIN_SIZE).dp) + return WindowState( + placement = if (maximized.get()) WindowPlacement.Maximized else WindowPlacement.Floating, + position = savedPosition(size) ?: WindowPosition.PlatformDefault, + size = size, + ) + } + + fun save(state: WindowState) { + maximized.set(state.placement == WindowPlacement.Maximized) + // Only remember the floating size, otherwise un-maximizing would keep the window full screen + if (state.placement != WindowPlacement.Floating) return + width.set(state.size.width.value) + height.set(state.size.height.value) + val position = state.position + if (position is WindowPosition.Absolute) { + x.set(position.x.value) + y.set(position.y.value) + } + } + + /** The saved position, unless the window would open on a monitor that is no longer connected */ + private fun savedPosition(size: DpSize): WindowPosition? { + val x = x.get() + val y = y.get() + if (x.isNaN() || y.isNaN()) return null + val titleBar = Rectangle(x.toInt(), y.toInt(), size.width.value.toInt(), 40) + val visible = runCatching { + GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices + .any { it.defaultConfiguration.bounds.intersects(titleBar) } + }.getOrDefault(false) + return if (visible) WindowPosition(x.dp, y.dp) else null + } + + private companion object { + const val MIN_SIZE = 400f + } +} + +/** A WindowState that starts where the window was last closed and saves every change */ +@OptIn(FlowPreview::class) +@Composable +fun rememberSavedWindowState(saved: SavedWindowState): WindowState { + val state = remember { saved.toWindowState() } + LaunchedEffect(state) { + snapshotFlow { Triple(state.placement, state.size, state.position) } + .debounce(500) + .collect { saved.save(state) } + } + return state +} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6bca3488e24..8d2b9ffb776 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -51,6 +51,12 @@ kotlin { jvmMain.dependencies { implementation(libs.ktor.client.java) + implementation(libs.kotlinx.serialization.json) + } + + jvmTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } } diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt new file mode 100644 index 00000000000..cd9ce5a22ed --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream4 + +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Where the desktop app keeps its files. + * + * Windows: %APPDATA%\CloudStream, macOS: ~/Library/Application Support/CloudStream, + * Linux: $XDG_CONFIG_HOME/CloudStream or ~/.config/CloudStream. + * + * Set the system property `cloudstream.home` to use another folder, for example a portable install. + */ +object AppDirs { + private const val APP_NAME = "CloudStream" + + val root: Path by lazy { + System.getProperty("cloudstream.home")?.takeIf { it.isNotBlank() }?.let { return@lazy Paths.get(it) } + + val home = System.getProperty("user.home") + val os = System.getProperty("os.name").orEmpty().lowercase() + when { + os.contains("win") -> Paths.get(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", APP_NAME) + os.contains("mac") -> Paths.get(home, "Library", "Application Support", APP_NAME) + else -> Paths.get(System.getenv("XDG_CONFIG_HOME") ?: "$home/.config", APP_NAME) + } + } + + val settingsFile: Path get() = root.resolve("settings.json") + val logs: Path get() = root.resolve("logs") +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt index cca2d70d79e..d0bb892f63e 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt @@ -1,16 +1,16 @@ package com.lagradost.cloudstream4 import androidx.compose.runtime.Composable -import com.mihon.common.preference.InMemoryPreferenceStore +import com.mihon.common.preference.JvmPreferenceStore +import com.mihon.common.preference.PreferenceStore -/** As this is mostly for testing so far, we can assume we have a shared global for settings */ -internal val settings = AppSettings() +/** The single preference file for the desktop app, shared by settings and app state such as window size */ +val desktopPreferences: PreferenceStore by lazy { JvmPreferenceStore(AppDirs.settingsFile) } + +/** The desktop app has one window, so a shared global for settings is enough */ +internal val settings by lazy { AppSettings(desktopPreferences) } @Composable actual fun rememberAppSettings(): AppSettings { return settings } - -fun AppSettings() : AppSettings { - return AppSettings(preferences = InMemoryPreferenceStore()) -} diff --git a/shared/src/jvmMain/kotlin/com/mihon/common/preference/JvmPreferenceStore.kt b/shared/src/jvmMain/kotlin/com/mihon/common/preference/JvmPreferenceStore.kt new file mode 100644 index 00000000000..734b233fb39 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/mihon/common/preference/JvmPreferenceStore.kt @@ -0,0 +1,206 @@ +package com.mihon.common.preference + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * Desktop implementation of PreferenceStore, keeping every preference in a single JSON file. + * + * Every write is saved right away by writing a temporary file and moving it over the old one, + * so a crash never leaves a half written file behind. A missing or unreadable file means defaults. + */ +class JvmPreferenceStore(private val file: Path) : PreferenceStore { + private val lock = Any() + private val values: MutableMap = load(file) + private val keyFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + internal fun read(key: String): JsonElement? = synchronized(lock) { values[key] } + + internal fun contains(key: String): Boolean = synchronized(lock) { values.containsKey(key) } + + /** Writes or, with a null value, removes a key and saves the file */ + internal fun write(key: String, value: JsonElement?) { + synchronized(lock) { + if (value == null) values.remove(key) else values[key] = value + save() + } + keyFlow.tryEmit(key) + } + + private fun save() { + file.parent?.createDirectories() + val tmp = file.resolveSibling("${file.fileName}.tmp") + tmp.writeText(JsonObject(values.toMap()).toString()) + Files.move(tmp, file, REPLACE_EXISTING, ATOMIC_MOVE) + } + + private fun preference( + key: String, + defaultValue: T, + decode: (JsonElement) -> T?, + encode: (T) -> JsonElement, + ): PreferenceData = JvmPreference(this, keyFlow, key, defaultValue, decode, encode) + + override fun getString(key: String, defaultValue: String): PreferenceData = + preference(key, defaultValue, { it.stringOrNull() }, ::JsonPrimitive) + + override fun getLong(key: String, defaultValue: Long): PreferenceData = + preference(key, defaultValue, { it.jsonPrimitive.longOrNull }, ::JsonPrimitive) + + override fun getInt(key: String, defaultValue: Int): PreferenceData = + preference(key, defaultValue, { it.jsonPrimitive.intOrNull }, ::JsonPrimitive) + + override fun getFloat(key: String, defaultValue: Float): PreferenceData = + preference(key, defaultValue, { it.jsonPrimitive.floatOrNull }, ::JsonPrimitive) + + override fun getBoolean(key: String, defaultValue: Boolean): PreferenceData = + preference(key, defaultValue, { it.jsonPrimitive.booleanOrNull }, ::JsonPrimitive) + + override fun getStringSet(key: String, defaultValue: Set): PreferenceData> = + preference(key, defaultValue, { it.stringSet() }, { it.toJsonArray() }) + + override fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): PreferenceData = preference( + key, + defaultValue, + { it.stringOrNull()?.let(deserializer) }, + { JsonPrimitive(serializer(it)) }, + ) + + override fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): PreferenceData = preference( + key, + defaultValue, + { it.jsonPrimitive.intOrNull?.let(deserializer) }, + { JsonPrimitive(serializer(it)) }, + ) + + override fun getObjectSetFromStringSet( + key: String, + defaultValue: Set, + serializer: (T) -> String, + deserializer: (String) -> T?, + ): PreferenceData> = preference( + key, + defaultValue, + { it.stringSet().mapNotNull(deserializer).toSet() }, + { set -> set.map(serializer).toSet().toJsonArray() }, + ) + + override fun getAll(): Map = synchronized(lock) { + values.mapValues { (_, value) -> + when (value) { + is JsonArray -> value.stringSet() + is JsonPrimitive -> value.stringOrNull() + ?: value.booleanOrNull + ?: value.longOrNull + ?: value.doubleOrNull + else -> null + } + } + } + + private companion object { + fun load(file: Path): MutableMap { + if (!file.exists()) return mutableMapOf() + return try { + Json.parseToJsonElement(file.readText()).jsonObject.toMutableMap() + } catch (t: Throwable) { + // Keep the unreadable file for debugging instead of silently overwriting it on the next write + println("ERROR JvmPreferenceStore: Could not read $file, using defaults: $t") + runCatching { Files.copy(file, file.resolveSibling("${file.fileName}.corrupt"), REPLACE_EXISTING) } + mutableMapOf() + } + } + + fun JsonElement.stringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + + fun JsonElement.stringSet(): Set = + jsonArray.mapNotNull { it.stringOrNull() }.toSet() + + fun Set.toJsonArray(): JsonArray = JsonArray(map(::JsonPrimitive)) + } +} + +class JvmPreference( + private val store: JvmPreferenceStore, + private val keyFlow: Flow, + private val key: String, + private val defaultValue: T, + private val decode: (JsonElement) -> T?, + private val encode: (T) -> JsonElement, +) : PreferenceData { + override fun key(): String = key + + /** A value of the wrong type, or one the deserializer rejects, reads as the default */ + override fun get(): T { + val stored = store.read(key) ?: return defaultValue + return runCatching { decode(stored) }.getOrNull() ?: defaultValue + } + + override fun set(value: T) { + store.write(key, encode(value)) + } + + override fun isSet(): Boolean = store.contains(key) + + override fun delete() { + store.write(key, null) + } + + override fun defaultValue(): T = defaultValue + + override fun changes(): Flow { + return keyFlow + .filter { it == key || it == null } + .onStart { emit("ignition") } + .map { get() } + .conflate() + } + + override fun stateIn(scope: CoroutineScope): StateFlow { + return changes().stateIn(scope, SharingStarted.Eagerly, get()) + } +} diff --git a/shared/src/jvmTest/kotlin/com/mihon/common/preference/JvmPreferenceStoreTest.kt b/shared/src/jvmTest/kotlin/com/mihon/common/preference/JvmPreferenceStoreTest.kt new file mode 100644 index 00000000000..f85352d477a --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/mihon/common/preference/JvmPreferenceStoreTest.kt @@ -0,0 +1,130 @@ +package com.mihon.common.preference + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JvmPreferenceStoreTest { + private enum class Quality { HD, SD, CAM } + + private val dir: Path = Files.createTempDirectory("cs-prefs") + private val file: Path = dir.resolve("settings.json") + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + @Test + fun valuesSurviveARestart() { + JvmPreferenceStore(file).apply { + getString("s").set("hello") + getInt("i").set(42) + getLong("l").set(9_000_000_000L) + getFloat("f").set(1.5f) + getBoolean("b").set(true) + getStringSet("set").set(setOf("a", "b")) + getEnumSet("enum_set", emptySet()).set(setOf(Quality.HD, Quality.CAM)) + getObjectFromInt("obj_int", Quality.SD, { it.ordinal }, { Quality.entries[it] }).set(Quality.CAM) + } + + val reopened = JvmPreferenceStore(file) + assertEquals("hello", reopened.getString("s").get()) + assertEquals(42, reopened.getInt("i").get()) + assertEquals(9_000_000_000L, reopened.getLong("l").get()) + assertEquals(1.5f, reopened.getFloat("f").get()) + assertEquals(true, reopened.getBoolean("b").get()) + assertEquals(setOf("a", "b"), reopened.getStringSet("set").get()) + assertEquals(setOf(Quality.HD, Quality.CAM), reopened.getEnumSet("enum_set", emptySet()).get()) + assertEquals( + Quality.CAM, + reopened.getObjectFromInt("obj_int", Quality.SD, { it.ordinal }, { Quality.entries[it] }).get() + ) + } + + @Test + fun missingFileGivesDefaultsAndIsNotCreatedUntilAWrite() { + val store = JvmPreferenceStore(file) + assertEquals(7, store.getInt("i", 7).get()) + assertFalse(store.getInt("i", 7).isSet()) + assertFalse(file.exists()) + + store.getInt("i", 7).set(8) + assertTrue(file.exists()) + } + + @Test + fun corruptFileGivesDefaultsAndIsKeptForDebugging() { + file.writeText("{ not json") + val store = JvmPreferenceStore(file) + assertEquals("default", store.getString("s", "default").get()) + assertTrue(dir.resolve("settings.json.corrupt").exists()) + + store.getString("s").set("works") + assertEquals("works", JvmPreferenceStore(file).getString("s").get()) + } + + @Test + fun wrongTypeReadsAsDefault() { + val store = JvmPreferenceStore(file) + store.getString("key").set("not a number") + assertEquals(5, store.getInt("key", 5).get()) + assertEquals(setOf("x"), store.getStringSet("key", setOf("x")).get()) + } + + @Test + fun deleteRemovesTheValueFromDisk() { + val store = JvmPreferenceStore(file) + store.getBoolean("b").set(true) + store.getBoolean("b").delete() + + assertFalse(store.getBoolean("b").isSet()) + assertFalse(JvmPreferenceStore(file).getBoolean("b").isSet()) + } + + @Test + fun noTemporaryFileIsLeftBehind() { + val store = JvmPreferenceStore(file) + repeat(5) { store.getInt("i").set(it) } + assertEquals(listOf("settings.json"), dir.listDirectoryEntries().map { it.fileName.toString() }) + } + + @Test + fun getAllReturnsPlainValues() { + val store = JvmPreferenceStore(file) + store.getString("s").set("text") + store.getBoolean("b").set(true) + store.getStringSet("set").set(setOf("a")) + val all = store.getAll() + assertEquals("text", all["s"]) + assertEquals(true, all["b"]) + assertEquals(setOf("a"), all["set"]) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun changesEmitsTheCurrentValueThenEachUpdate() = runTest { + val store = JvmPreferenceStore(file) + val pref = store.getInt("i", 0) + val seen = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { pref.changes().toList(seen) } + + pref.set(1) + store.getInt("other").set(99) // Other keys must not emit + pref.set(2) + + assertEquals(listOf(0, 1, 2), seen) + } +} From ebcb4a784557a6427575845887c0e4b36806e4a9 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 00:22:11 +0530 Subject: [PATCH 03/64] Desktop: navigation rail and settings screens - AppShell with a navigation rail (Home, Search, Library, Settings); Ctrl+1..4 switch destination, Escape / Alt+Left go back - Desktop settings screens (General, Player, Layout, Providers) built on the shared Mihon preference widgets and AppSettings keys, leaving out Android-only options (TV, gestures, battery, rotation, PiP, APK) - Theme and primary color follow the settings live - English strings and vector icons copied from app/res into the shared compose resources, keeping the Android names Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 157 +++++++ .../kotlin/com/lagradost/cloudstream4/Main.kt | 54 +-- .../cloudstream4/settings/DesktopSettings.kt | 425 ++++++++++++++++++ .../cloudstream4/settings/DesktopTheme.kt | 41 ++ .../drawable/aspect_ratio_24px.xml | 10 + .../drawable/audio_capture_24px.xml | 10 + .../drawable/autorenew_24px.xml | 10 + .../drawable/baseline_grid_view_24.xml | 11 + .../drawable/baseline_theaters_24.xml | 10 + .../composeResources/drawable/benene.xml | 58 +++ .../composeResources/drawable/build_24px.xml | 10 + .../composeResources/drawable/colors_24px.xml | 10 + .../drawable/description_24px.xml | 10 + .../drawable/extension_24px.xml | 10 + .../composeResources/drawable/face_24px.xml | 10 + .../drawable/filter_alt_24px.xml | 10 + .../drawable/format_paint_24px.xml | 10 + .../drawable/home_icon_filled_24.xml | 9 + .../drawable/home_icon_outline_24.xml | 9 + .../drawable/ic_baseline_discord_24.xml | 6 + .../drawable/ic_baseline_speed_24.xml | 5 + .../drawable/ic_github_logo.xml | 5 + .../keyboard_double_arrow_right_24px.xml | 11 + .../composeResources/drawable/kitsu_icon.xml | 15 + .../drawable/library_icon.xml | 10 + .../drawable/library_icon_filled.xml | 10 + .../drawable/metadata_overlay_icon.xml | 36 ++ .../drawable/movie_edit_24px.xml | 10 + .../drawable/palette_24px.xml | 10 + .../composeResources/drawable/pause_24px.xml | 10 + .../picture_in_picture_center_24px.xml | 10 + .../drawable/play_arrow_24px.xml | 10 + .../composeResources/drawable/plugin_lang.xml | 12 + .../drawable/quick_novel_icon.xml | 23 + .../composeResources/drawable/search_icon.xml | 10 + .../drawable/settings_icon_filled.xml | 9 + .../drawable/settings_icon_outline.xml | 9 + .../drawable/shuffle_24px.xml | 10 + .../drawable/skip_next_24px.xml | 10 + .../composeResources/drawable/title_24px.xml | 10 + .../drawable/tooltip_24px.xml | 10 + .../composeResources/values/strings.xml | 103 ++++- 42 files changed, 1191 insertions(+), 37 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt create mode 100644 shared/src/commonMain/composeResources/drawable/aspect_ratio_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/audio_capture_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/autorenew_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/baseline_grid_view_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/baseline_theaters_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/benene.xml create mode 100644 shared/src/commonMain/composeResources/drawable/build_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/colors_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/description_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/extension_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/face_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/filter_alt_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/format_paint_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/home_icon_filled_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/home_icon_outline_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_discord_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_speed_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_github_logo.xml create mode 100644 shared/src/commonMain/composeResources/drawable/keyboard_double_arrow_right_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/kitsu_icon.xml create mode 100644 shared/src/commonMain/composeResources/drawable/library_icon.xml create mode 100644 shared/src/commonMain/composeResources/drawable/library_icon_filled.xml create mode 100644 shared/src/commonMain/composeResources/drawable/metadata_overlay_icon.xml create mode 100644 shared/src/commonMain/composeResources/drawable/movie_edit_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/palette_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/pause_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/picture_in_picture_center_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/play_arrow_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/plugin_lang.xml create mode 100644 shared/src/commonMain/composeResources/drawable/quick_novel_icon.xml create mode 100644 shared/src/commonMain/composeResources/drawable/search_icon.xml create mode 100644 shared/src/commonMain/composeResources/drawable/settings_icon_filled.xml create mode 100644 shared/src/commonMain/composeResources/drawable/settings_icon_outline.xml create mode 100644 shared/src/commonMain/composeResources/drawable/shuffle_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/skip_next_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/title_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/tooltip_24px.xml diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt new file mode 100644 index 00000000000..e18f9d0780b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -0,0 +1,157 @@ +package com.lagradost.cloudstream4 + +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.settings.SettingsHomeScreen +import com.mihon.presentation.LocalBackPress +import com.mihon.presentation.settings.SearchableSettings +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +enum class Destination( + val title: StringResource, + val icon: DrawableResource, + val selectedIcon: DrawableResource, +) { + Home(Res.string.title_home, Res.drawable.home_icon_outline_24, Res.drawable.home_icon_filled_24), + Search(Res.string.title_search, Res.drawable.search_icon, Res.drawable.search_icon), + Library(Res.string.library, Res.drawable.library_icon, Res.drawable.library_icon_filled), + Settings(Res.string.title_settings, Res.drawable.settings_icon_outline, Res.drawable.settings_icon_filled), +} + +/** + * Navigation for the desktop app: a rail on the left and the selected destination on the right. + * Ctrl+1 to Ctrl+4 switch destination, Escape or Alt+Left goes back. + */ +class AppNavigator { + var destination by mutableStateOf(Destination.Home) + private set + + /** Settings pages opened on top of the settings home, last is shown */ + val settingsStack = mutableStateListOf() + + fun select(destination: Destination) { + // Selecting Settings again returns to the settings home + if (destination == Destination.Settings && this.destination == Destination.Settings) settingsStack.clear() + this.destination = destination + } + + fun open(page: SearchableSettings) { + settingsStack.add(page) + } + + /** Returns false when there is nothing to go back to */ + fun back(): Boolean { + if (destination == Destination.Settings && settingsStack.isNotEmpty()) { + settingsStack.removeAt(settingsStack.lastIndex) + return true + } + return false + } + + fun onKeyEvent(event: KeyEvent): Boolean { + if (event.type != KeyEventType.KeyDown) return false + if (event.isCtrlPressed) { + val index = listOf(Key.One, Key.Two, Key.Three, Key.Four).indexOf(event.key) + if (index >= 0) { + select(Destination.entries[index]) + return true + } + } + if (event.key == Key.Escape || (event.isAltPressed && event.key == Key.DirectionLeft)) return back() + return false + } +} + +@Composable +fun AppShell(navigator: AppNavigator) { + Row(Modifier.fillMaxSize()) { + NavigationRail( + modifier = Modifier.fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + Destination.entries.forEach { destination -> + val selected = navigator.destination == destination + NavigationRailItem( + selected = selected, + onClick = { navigator.select(destination) }, + icon = { + Icon( + painterResource(if (selected) destination.selectedIcon else destination.icon), + contentDescription = null, + ) + }, + label = { Text(stringResource(destination.title)) }, + ) + } + } + } + VerticalDivider() + Box(Modifier.weight(1f).fillMaxHeight()) { + when (navigator.destination) { + Destination.Settings -> { + val page = navigator.settingsStack.lastOrNull() + ?: remember(navigator) { SettingsHomeScreen(navigator::open) } + val back: (() -> Unit)? = if (navigator.settingsStack.isEmpty()) null else ({ navigator.back() }) + CompositionLocalProvider(LocalBackPress provides back) { + page.Content() + } + } + + else -> ComingSoon(navigator.destination) + } + } + } +} + +/** Placeholder for the destinations built in later phases */ +@Composable +private fun ComingSoon(destination: Destination) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringResource(destination.title), style = MaterialTheme.typography.headlineMedium) + Text( + "Not built yet on desktop", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 8c0acdaf3a7..e4bbbc778a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -1,10 +1,7 @@ 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.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState @@ -12,17 +9,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.lagradost.cloudstream4.compose.BlackButton import com.lagradost.cloudstream4.compose.LocalFocusOutlineDefault -import com.lagradost.cloudstream4.compose.WhiteButton import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.app_name import com.lagradost.cloudstream4.generated.resources.default_icon -import com.lagradost.cloudstream4.generated.resources.preview +import com.lagradost.cloudstream4.settings.desktopPrimaryColor +import com.lagradost.cloudstream4.settings.desktopThemeMode import com.lagradost.cloudstream4.theme.CloudStreamTheme -import com.lagradost.cloudstream4.theme.CloudStreamThemeMode -import com.mihon.common.preference.toggle -import com.mihon.presentation.settings.widget.SwitchPreferenceWidget import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -31,6 +24,7 @@ fun main() { application { val savedWindow = remember { SavedWindowState(desktopPreferences) } val windowState = rememberSavedWindowState(savedWindow) + val navigator = remember { AppNavigator() } Window( onCloseRequest = { savedWindow.save(windowState) @@ -38,40 +32,28 @@ fun main() { }, state = windowState, title = stringResource(Res.string.app_name), - icon = painterResource(Res.drawable.default_icon) + icon = painterResource(Res.drawable.default_icon), + onPreviewKeyEvent = navigator::onKeyEvent, ) { - MainContent() + MainContent(navigator) } } } @Composable -private fun MainContent() { - val startPaused = rememberAppSettings().player.startPaused - CloudStreamTheme(mode = CloudStreamThemeMode.Dark) { +private fun MainContent(navigator: AppNavigator) { + val ui = rememberAppSettings().ui + // Changing the theme or color in settings applies right away + val theme by ui.theme.changes().collectAsState(ui.theme.get()) + val primaryColor by ui.primaryColor.changes().collectAsState(ui.primaryColor.get()) + + CloudStreamTheme(mode = desktopThemeMode(theme), primaryColor = desktopPrimaryColor(primaryColor)) { CompositionLocalProvider(LocalFocusOutlineDefault provides false) { - Scaffold( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground + Surface( + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, ) { - Column { - Text("Hello, World!") - Row { - WhiteButton("Hello in White") { - } - BlackButton("Hello in Black") { - } - } - // Placeholder until the settings screens are connected, this one is saved to disk - val checked by startPaused.changes().collectAsState(startPaused.get()) - SwitchPreferenceWidget( - title = "Start videos paused", subtitle = "Saved between restarts", icon = painterResource( - Res.drawable.preview - ), - checked = checked, - onCheckedChanged = { startPaused.toggle() } - ) - } + AppShell(navigator) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt new file mode 100644 index 00000000000..68843a43cab --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -0,0 +1,425 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.rememberAppSettings +import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor +import com.lagradost.cloudstream4.theme.modeToTheme +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.collections.immutable.persistentListOf +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/* + * Desktop versions of the settings screens in app/ui/settings. They use the same AppSettings keys, + * but leave out what only applies to Android: TV layout, touch gestures, battery, rotation, PiP, + * APK updates and biometrics. Accounts, updates, backups, downloads and subtitles come with the + * phases that add those features. + */ + +/** The settings home: one entry per category */ +class SettingsHomeScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.title_settings) + + @Composable + override fun getPreferences(): List = listOf( + category(SettingsGeneralScreen, Res.drawable.build_24px), + category(SettingsPlayerScreen, Res.drawable.play_arrow_24px), + category(SettingsLayoutScreen, Res.drawable.format_paint_24px), + category(SettingsProvidersScreen, Res.drawable.extension_24px), + ) + + @Composable + private fun category(screen: SearchableSettings, icon: org.jetbrains.compose.resources.DrawableResource) = + Preference.PreferenceItem.TextPreference( + title = screen.getTitleRes(), + icon = painterResource(icon), + onClick = { open(screen) }, + ) +} + +object SettingsGeneralScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_general) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + val uriHandler = LocalUriHandler.current + val bananas = settings.general.bananas.get() + + fun link(title: String, url: String, icon: Painter) = Preference.PreferenceItem.TextPreference( + title = title, + subtitle = url, + icon = icon, + onClick = { runCatching { uriHandler.openUri(url) } }, + ) + + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_links), + preferenceItems = persistentListOf( + link( + stringResource(Res.string.github), + "https://github.com/recloudstream/cloudstream", + painterResource(Res.drawable.ic_github_logo) + ), + link( + stringResource(Res.string.lightnovel), + "https://github.com/LagradOst/QuickNovel", + painterResource(Res.drawable.quick_novel_icon) + ), + link( + stringResource(Res.string.discord), + "https://discord.gg/5Hus6fM", + painterResource(Res.drawable.ic_baseline_discord_24) + ), + link( + stringResource(Res.string.cs3wiki), + "https://cloudstream.miraheze.org/", + painterResource(Res.drawable.description_24px) + ), + ) + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(Res.string.benene), + subtitle = if (bananas == 0) { + stringResource(Res.string.benene_count_text_none) + } else { + stringResource(Res.string.benene_count_text, bananas) + }, + onClick = { settings.general.bananas.set(bananas + 1) }, + icon = painterResource(Res.drawable.benene), + ), + Preference.PreferenceItem.InfoPreference(title = stringResource(Res.string.legal_notice_text)), + ) + } +} + +object SettingsPlayerScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_player) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_player_features), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.resizeEnabled, + title = stringResource(Res.string.player_size_settings), + subtitle = stringResource(Res.string.player_size_settings_des), + icon = painterResource(Res.drawable.aspect_ratio_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.speedEnabled, + title = stringResource(Res.string.eigengraumode_settings), + subtitle = stringResource(Res.string.speed_setting_summary), + icon = painterResource(Res.drawable.ic_baseline_speed_24), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.autoPlayEnabled, + title = stringResource(Res.string.autoplay_next_settings), + subtitle = stringResource(Res.string.autoplay_next_settings_des), + icon = painterResource(Res.drawable.skip_next_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.startPaused, + title = stringResource(Res.string.start_paused_settings), + subtitle = stringResource(Res.string.start_paused_settings_des), + icon = painterResource(Res.drawable.pause_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.skipOpEnabled, + title = stringResource(Res.string.video_skip_op), + subtitle = stringResource(Res.string.enable_skip_op_from_database_des), + icon = painterResource(Res.drawable.keyboard_double_arrow_right_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.previewBarEnabled, + title = stringResource(Res.string.preview_seekbar), + subtitle = stringResource(Res.string.preview_seekbar_desc), + icon = painterResource(Res.drawable.picture_in_picture_center_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.episodeSync, + title = stringResource(Res.string.episode_sync_settings), + subtitle = stringResource(Res.string.episode_sync_settings_des), + icon = painterResource(Res.drawable.autorenew_24px) + ), + ) + ), + ) + } +} + +object SettingsLayoutScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_ui) + + /** Same values and names as R.array.themes_names_values, without Material You which needs Android 12 */ + private val themes = listOf( + "AmoledLight" to "Dark", "Black" to "Gray", "Amoled" to "Amoled", "Light" to "Flashbang", + "System" to "System", "Dracula" to "Dracula", "Lavender" to "Lavender Dreams", "SilentBlue" to "Silent Blue", + ) + + /** Same values as R.array.themes_overlay_names_values, without the Material You colors */ + private val primaryColors = listOf( + "Normal" to "Normal", "DandelionYellow" to "Dandelion Yellow", "CarnationPink" to "Carnation Pink", + "Orange" to "Orange", "DarkGreen" to "Dark Green", "Maroon" to "Maroon", "NavyBlue" to "Navy Blue", + "Grey" to "Grey", "White" to "White", "CoolBlue" to "Cool Blue", "Brown" to "Brown", "Blue" to "Cool", + "Red" to "Fire", "Purple" to "Burple", "Green" to "Green", "GreenApple" to "Apple", "Banana" to "Banana", + "Party" to "Party", "Pink" to "Pink Pain", "Lavender" to "Lavender", + ) + + @Composable + private fun RoundColor(color: Color) { + Box( + modifier = Modifier + .padding(start = 15.dp) + .size(20.dp) + .border(width = 1.5.dp, shape = CircleShape, color = MaterialTheme.colorScheme.onBackground) + .background(color, CircleShape) + ) + } + + @Composable + private fun SearchQuality.label(): String = stringResource( + when (this) { + SearchQuality.BlueRay -> Res.string.quality_blueray + SearchQuality.Cam -> Res.string.quality_cam + SearchQuality.CamRip -> Res.string.quality_cam_rip + SearchQuality.DVD -> Res.string.quality_dvd + SearchQuality.HD -> Res.string.quality_hd + SearchQuality.HQ -> Res.string.quality_hq + SearchQuality.HdCam -> Res.string.quality_cam_hd + SearchQuality.Telecine -> Res.string.quality_tc + SearchQuality.Telesync -> Res.string.quality_ts + SearchQuality.WorkPrint -> Res.string.quality_workprint + SearchQuality.SD -> Res.string.quality_sd + SearchQuality.FourK -> Res.string.quality_4k + SearchQuality.UHD -> Res.string.quality_uhd + SearchQuality.SDR -> Res.string.quality_sdr + SearchQuality.HDR -> Res.string.quality_hdr + SearchQuality.WebRip -> Res.string.quality_webrip + } + ) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_looks), + preferenceItems = persistentListOf( + // The theme follows these two settings live, see MainContent + Preference.PreferenceItem.ListPreference( + preference = settings.ui.primaryColor, + icon = painterResource(Res.drawable.colors_24px), + title = stringResource(Res.string.primary_color_settings), + entries = primaryColors.toMap(), + iconProvider = { key, _ -> RoundColor(desktopPrimaryColor(key).color) }, + ), + Preference.PreferenceItem.ListPreference( + preference = settings.ui.theme, + icon = painterResource(Res.drawable.palette_24px), + title = stringResource(Res.string.app_theme_settings), + entries = themes.toMap(), + iconProvider = { key, _ -> + RoundColor(modeToTheme(desktopThemeMode(key), CloudStreamPrimaryColor.NORMAL).background) + }, + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(Res.string.pref_category_ui_features), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.advancedSearch, + title = stringResource(Res.string.advanced_search), + subtitle = stringResource(Res.string.advanced_search_des), + icon = painterResource(Res.drawable.search_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.searchSuggestions, + title = stringResource(Res.string.search_suggestions), + subtitle = stringResource(Res.string.search_suggestions_des), + icon = painterResource(Res.drawable.tooltip_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.trailersEnabled, + title = stringResource(Res.string.show_trailers_settings), + icon = painterResource(Res.drawable.baseline_theaters_24) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.kitsuPostersEnabled, + title = stringResource(Res.string.kitsu_settings), + icon = painterResource(Res.drawable.kitsu_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.castEnabled, + title = stringResource(Res.string.show_cast_in_details), + icon = painterResource(Res.drawable.face_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.fillersEnabled, + title = stringResource(Res.string.show_fillers_settings), + icon = painterResource(Res.drawable.skip_next_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.showMetadataOverlay, + title = stringResource(Res.string.show_player_metadata_overlay), + icon = painterResource(Res.drawable.metadata_overlay_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.randomButtonEnabled, + title = stringResource(Res.string.random_button_settings), + subtitle = stringResource(Res.string.random_button_settings_desc), + icon = painterResource(Res.drawable.shuffle_24px) + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(Res.string.search_poster_img_des), + preferenceItems = persistentListOf( + Preference.PreferenceItem.MultiSelectListPreference( + preference = settings.ui.filterQuality, + title = stringResource(Res.string.pref_filter_search_quality), + icon = painterResource(Res.drawable.filter_alt_24px), + entries = SearchQuality.entries.associateWith { it.label() } + ), + dubSubPreference(), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.bottomTitle, + title = stringResource(Res.string.bottom_title_settings), + subtitle = stringResource(Res.string.bottom_title_settings_des), + icon = painterResource(Res.drawable.title_24px) + ), + Preference.PreferenceItem.SliderPreference( + preference = settings.ui.posterSize, + title = stringResource(Res.string.poster_size_settings), + subtitle = stringResource(Res.string.poster_size_settings_des), + valueRange = 0..15, + icon = painterResource(Res.drawable.baseline_grid_view_24), + ), + ), + ), + Preference.PreferenceGroup( + title = stringResource(Res.string.poster_ui_settings), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowRating, + title = stringResource(Res.string.show_rating), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowEpisode, + title = stringResource(Res.string.show_episode_text), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowTitle, + title = stringResource(Res.string.show_title), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowHd, + title = stringResource(Res.string.show_hd), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowDub, + title = stringResource(Res.string.show_dub), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowSub, + title = stringResource(Res.string.show_sub), + ), + ) + ), + ) + } +} + +object SettingsProvidersScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(Res.string.category_providers) + + @Composable + private fun TvType.label(): String = stringResource( + when (this) { + TvType.TvSeries -> Res.string.tv_series_singular + TvType.Anime -> Res.string.anime_singular + TvType.OVA -> Res.string.ova_singular + TvType.AnimeMovie -> Res.string.movies_singular + TvType.Cartoon -> Res.string.cartoons_singular + TvType.Documentary -> Res.string.documentaries_singular + TvType.Movie -> Res.string.movies_singular + TvType.Torrent -> Res.string.torrent_singular + TvType.AsianDrama -> Res.string.asian_drama_singular + TvType.Live -> Res.string.live_singular + TvType.Others -> Res.string.other_singular + TvType.NSFW -> Res.string.nsfw_singular + TvType.Music -> Res.string.music_singular + TvType.AudioBook -> Res.string.audio_book_singular + TvType.CustomMedia -> Res.string.custom_media_singular + TvType.Audio -> Res.string.audio_singular + TvType.Podcast -> Res.string.podcast_singular + TvType.Video -> Res.string.video_singular + } + ) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + // Empty until plugins can be loaded on desktop, then each provider adds its language + val languages = APIHolder.apis.withLock { APIHolder.apis.map { it.lang }.distinct() }.sorted() + + return persistentListOf( + Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(Res.string.provider_lang_settings), + icon = painterResource(Res.drawable.plugin_lang), + entries = mapOf(AllLanguagesName to stringResource(Res.string.all_languages_preference)) + + languages.associateWith { it }, + preference = settings.provider.extensionLanguages + ), + Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(Res.string.preferred_media_settings), + icon = painterResource(Res.drawable.movie_edit_24px), + preference = settings.provider.preferredMedia, + entries = TvType.entries.associate { it.ordinal.toString() to it.label() } + ), + dubSubPreference(), + ) + } +} + +@Composable +private fun dubSubPreference() = Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(Res.string.display_subbed_dubbed_settings), + icon = painterResource(Res.drawable.audio_capture_24px), + preference = rememberAppSettings().provider.displayDubSub, + entries = mapOf( + DubStatus.None.name to stringResource(Res.string.none), + DubStatus.Dubbed.name to stringResource(Res.string.app_dubbed_text), + DubStatus.Subbed.name to stringResource(Res.string.app_subbed_text), + ) +) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt new file mode 100644 index 00000000000..ded34436d9e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt @@ -0,0 +1,41 @@ +package com.lagradost.cloudstream4.settings + +import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor +import com.lagradost.cloudstream4.theme.CloudStreamThemeMode + +/** Desktop version of perfToMode in shared/androidMain, without Material You */ +fun desktopThemeMode(value: String?): CloudStreamThemeMode = when (value) { + "System" -> CloudStreamThemeMode.FollowSystem + "Black" -> CloudStreamThemeMode.Dark + "Light" -> CloudStreamThemeMode.Light + "Amoled" -> CloudStreamThemeMode.Amoled + "AmoledLight" -> CloudStreamThemeMode.AmoledLight + "Dracula" -> CloudStreamThemeMode.Dracula + "Lavender" -> CloudStreamThemeMode.Lavender + "SilentBlue" -> CloudStreamThemeMode.SilentBlue + else -> CloudStreamThemeMode.Dark +} + +/** Desktop version of perfToColor in shared/androidMain, without Material You */ +fun desktopPrimaryColor(value: String?): CloudStreamPrimaryColor = when (value) { + "Blue" -> CloudStreamPrimaryColor.BLUE + "Purple" -> CloudStreamPrimaryColor.PURPLE + "Green" -> CloudStreamPrimaryColor.GREEN + "GreenApple" -> CloudStreamPrimaryColor.GREEN_APPLE + "Red" -> CloudStreamPrimaryColor.RED + "Banana" -> CloudStreamPrimaryColor.BANANA + "Party" -> CloudStreamPrimaryColor.PARTY + "Pink" -> CloudStreamPrimaryColor.PINK + "CarnationPink" -> CloudStreamPrimaryColor.CARNATION_PINK + "Maroon" -> CloudStreamPrimaryColor.MAROON + "DarkGreen" -> CloudStreamPrimaryColor.DARK_GREEN + "NavyBlue" -> CloudStreamPrimaryColor.NAVY_BLUE + "Grey" -> CloudStreamPrimaryColor.GREY + "White" -> CloudStreamPrimaryColor.WHITE + "Brown" -> CloudStreamPrimaryColor.BROWN + "Orange" -> CloudStreamPrimaryColor.ORANGE + "DandelionYellow" -> CloudStreamPrimaryColor.DANDELION_YELLOW + "CoolBlue" -> CloudStreamPrimaryColor.COOL_BLUE + "Lavender" -> CloudStreamPrimaryColor.LAVENDER + else -> CloudStreamPrimaryColor.NORMAL +} diff --git a/shared/src/commonMain/composeResources/drawable/aspect_ratio_24px.xml b/shared/src/commonMain/composeResources/drawable/aspect_ratio_24px.xml new file mode 100644 index 00000000000..8c8765ba80b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/aspect_ratio_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/audio_capture_24px.xml b/shared/src/commonMain/composeResources/drawable/audio_capture_24px.xml new file mode 100644 index 00000000000..a0a9e579530 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/audio_capture_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/autorenew_24px.xml b/shared/src/commonMain/composeResources/drawable/autorenew_24px.xml new file mode 100644 index 00000000000..70cd9d51c39 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/autorenew_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/baseline_grid_view_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_grid_view_24.xml new file mode 100644 index 00000000000..d946d1194d4 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_grid_view_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/baseline_theaters_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_theaters_24.xml new file mode 100644 index 00000000000..6f2f04c928e --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_theaters_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/benene.xml b/shared/src/commonMain/composeResources/drawable/benene.xml new file mode 100644 index 00000000000..61a861faf71 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/benene.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/build_24px.xml b/shared/src/commonMain/composeResources/drawable/build_24px.xml new file mode 100644 index 00000000000..5ffb556448e --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/build_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/colors_24px.xml b/shared/src/commonMain/composeResources/drawable/colors_24px.xml new file mode 100644 index 00000000000..dfc788bf9a0 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/colors_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/description_24px.xml b/shared/src/commonMain/composeResources/drawable/description_24px.xml new file mode 100644 index 00000000000..a71f1fc20bc --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/description_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/extension_24px.xml b/shared/src/commonMain/composeResources/drawable/extension_24px.xml new file mode 100644 index 00000000000..8d6e9b56b93 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/extension_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/face_24px.xml b/shared/src/commonMain/composeResources/drawable/face_24px.xml new file mode 100644 index 00000000000..b9153f36698 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/face_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/filter_alt_24px.xml b/shared/src/commonMain/composeResources/drawable/filter_alt_24px.xml new file mode 100644 index 00000000000..5931b6958b9 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/filter_alt_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/format_paint_24px.xml b/shared/src/commonMain/composeResources/drawable/format_paint_24px.xml new file mode 100644 index 00000000000..396c61a8b7a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/format_paint_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/home_icon_filled_24.xml b/shared/src/commonMain/composeResources/drawable/home_icon_filled_24.xml new file mode 100644 index 00000000000..303b6a10c05 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/home_icon_filled_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/home_icon_outline_24.xml b/shared/src/commonMain/composeResources/drawable/home_icon_outline_24.xml new file mode 100644 index 00000000000..2d5f93a674d --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/home_icon_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_discord_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_discord_24.xml new file mode 100644 index 00000000000..19b153c43fb --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_discord_24.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_speed_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_speed_24.xml new file mode 100644 index 00000000000..94177781988 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_speed_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_github_logo.xml b/shared/src/commonMain/composeResources/drawable/ic_github_logo.xml new file mode 100644 index 00000000000..27d704b59dd --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_github_logo.xml @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/keyboard_double_arrow_right_24px.xml b/shared/src/commonMain/composeResources/drawable/keyboard_double_arrow_right_24px.xml new file mode 100644 index 00000000000..fcb76975a0a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/keyboard_double_arrow_right_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/kitsu_icon.xml b/shared/src/commonMain/composeResources/drawable/kitsu_icon.xml new file mode 100644 index 00000000000..25811873422 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/kitsu_icon.xml @@ -0,0 +1,15 @@ + + + + diff --git a/shared/src/commonMain/composeResources/drawable/library_icon.xml b/shared/src/commonMain/composeResources/drawable/library_icon.xml new file mode 100644 index 00000000000..b67c2e51d26 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/library_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/library_icon_filled.xml b/shared/src/commonMain/composeResources/drawable/library_icon_filled.xml new file mode 100644 index 00000000000..30a40902ec4 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/library_icon_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/metadata_overlay_icon.xml b/shared/src/commonMain/composeResources/drawable/metadata_overlay_icon.xml new file mode 100644 index 00000000000..818d408c8d7 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/movie_edit_24px.xml b/shared/src/commonMain/composeResources/drawable/movie_edit_24px.xml new file mode 100644 index 00000000000..f76f999e506 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/movie_edit_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/palette_24px.xml b/shared/src/commonMain/composeResources/drawable/palette_24px.xml new file mode 100644 index 00000000000..712cfd1fd0d --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/palette_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/pause_24px.xml b/shared/src/commonMain/composeResources/drawable/pause_24px.xml new file mode 100644 index 00000000000..5bb9281eb20 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/pause_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/picture_in_picture_center_24px.xml b/shared/src/commonMain/composeResources/drawable/picture_in_picture_center_24px.xml new file mode 100644 index 00000000000..9a0c919363c --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/picture_in_picture_center_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/play_arrow_24px.xml b/shared/src/commonMain/composeResources/drawable/play_arrow_24px.xml new file mode 100644 index 00000000000..322be00eca6 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/play_arrow_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/plugin_lang.xml b/shared/src/commonMain/composeResources/drawable/plugin_lang.xml new file mode 100644 index 00000000000..ac33489ac11 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/plugin_lang.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/quick_novel_icon.xml b/shared/src/commonMain/composeResources/drawable/quick_novel_icon.xml new file mode 100644 index 00000000000..8f0b3cf3e7b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/quick_novel_icon.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/search_icon.xml b/shared/src/commonMain/composeResources/drawable/search_icon.xml new file mode 100644 index 00000000000..41ca1103de3 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/search_icon.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/settings_icon_filled.xml b/shared/src/commonMain/composeResources/drawable/settings_icon_filled.xml new file mode 100644 index 00000000000..1d31bb7d024 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/settings_icon_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/settings_icon_outline.xml b/shared/src/commonMain/composeResources/drawable/settings_icon_outline.xml new file mode 100644 index 00000000000..bdc9e98d38b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/settings_icon_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/shuffle_24px.xml b/shared/src/commonMain/composeResources/drawable/shuffle_24px.xml new file mode 100644 index 00000000000..bed4f19ba7f --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/shuffle_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/skip_next_24px.xml b/shared/src/commonMain/composeResources/drawable/skip_next_24px.xml new file mode 100644 index 00000000000..4cb20d68f3e --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/skip_next_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/title_24px.xml b/shared/src/commonMain/composeResources/drawable/title_24px.xml new file mode 100644 index 00000000000..77f74466d38 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/title_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/tooltip_24px.xml b/shared/src/commonMain/composeResources/drawable/tooltip_24px.xml new file mode 100644 index 00000000000..02f77939ffa --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/tooltip_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index e5eca5dd287..1a11146d58a 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -11,4 +11,105 @@ Navigate up Lorem ipsum Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - \ No newline at end of file + + + Advanced Search + Gives you the search results separated by provider + All Languages + Anime + Dub + Sub + App theme + Asian Drama + Audio Book + Audio + Autoplay next episode + Start the next episode when the current one ends + Give a benene to the devs + %1$d Benenes given to devs + No Benenes given + Poster title location + Put the title under the poster + Cartoon + General + Player + Providers + Layout + CloudStream Wiki + Media + Join Discord + Display Dubbed/Subbed Anime + Documentary + Playback speed + Show skip popups for opening/ending + Update watch progress + Automatically sync your current episode progress + Github + Show posters from Kitsu + Library + Light novel app by the same devs + Livestream + Movie + Music + NSFW + Video + OVA + Player resize button + Remove the black borders + Podcast + Poster size + Changes size of posters + Toggle UI elements on poster + Links + Looks + Player features + Features + Hide selected video quality in search results + Preferred media + Seekbar preview + Enable preview thumbnail on seekbar + Primary color + Extension languages + 4K + Blu-ray + Cam + CamHD + CamRip + DVD + HD + HDR + HQ + SD + SDR + TC + TS + UHD + Web + WP + Random Button + Show random button on Homepage and Library + Poster + Search Suggestions + Show search suggestions while typing + Show cast panel + Dub label + Episode Text + Show filler episode for anime + Quality label + Show Player Metadata Overlay + Rating Label + Sub label + Title + Show trailers + Adds a speed option in the player + Start videos paused + Reduce stress when selecting sources + Home + Search + Settings + Torrent + Series + Video + Skip OP + Any legal issues regarding the content on this application should be taken up with the actual file hosts and providers themselves as we are not affiliated with them.\n\nIn case of copyright infringement, please directly contact the responsible parties or the streaming websites.\n\nThe app is purely for educational and personal use.\n\nCloudStream does not host any content on the app, and has no control over what media is put up or taken down. CloudStream functions like any other search engine, such as Google. CloudStream does not host, upload or manage any videos, films or content. It simply crawls, aggregates and displays links in a convenient, user-friendly interface.\n\nIt merely scrapes 3rd-party websites that are publicly accessible via any regular web browser. It is the responsibility of user to avoid any actions that might violate the laws governing his/her locality. Use CloudStream at your own risk. + From ed95a165c4e322fe2e1fa42573777682d13d0259 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 00:46:08 +0530 Subject: [PATCH 04/64] Desktop: install and load extensions from repositories - PluginLoader loads the cross-platform .jar that extensions publish with isCrossPlatform = true; the jar has no manifest.json, so the plugin class is found by its @CloudstreamPlugin annotation. Unloading removes the plugin's providers and extractors and closes the class loader so Windows can replace or delete the jar - RepositoryClient reads repo.json and plugin lists (including jarUrl, jarHash, jarFileSize) and verifies size and SHA-256 of downloads. It uses OkHttp because the JDK HTTP clients only try the first resolved address, and one GitHub address is unreachable on some ISPs - DesktopPluginManager installs into /plugins, keeps installed.json, loads everything at startup in the background and records per-plugin load errors - Settings > Extensions: add/remove repositories (one click for the official one), install/update/uninstall, Android-only plugins listed as such - Tests: annotation scan, load/unload, list parsing, install/restart/ uninstall and hash rejection against a local HTTP server, plus an opt-in online test (CLOUDSTREAM_ONLINE_TESTS=1) that installs a real plugin from the official repository and searches with it Co-Authored-By: Claude Opus 5.5 (1M context) --- .../kotlin/com/lagradost/cloudstream4/Main.kt | 8 + .../cloudstream4/settings/DesktopSettings.kt | 3 +- .../settings/ExtensionsSettings.kt | 251 ++++++++++++++++++ gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 1 + .../plugins/DesktopPluginManager.kt | 154 +++++++++++ .../cloudstream4/plugins/PluginLoader.kt | 100 +++++++ .../cloudstream4/plugins/RepositoryClient.kt | 140 ++++++++++ .../cloudstream4/plugins/PluginTest.kt | 187 +++++++++++++ .../cloudstream4/plugins/TestPlugin.kt | 31 +++ 10 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/TestPlugin.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index e4bbbc778a3..b29bbefee18 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -13,14 +13,22 @@ import com.lagradost.cloudstream4.compose.LocalFocusOutlineDefault 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.plugins.DesktopPluginManager import com.lagradost.cloudstream4.settings.desktopPrimaryColor import com.lagradost.cloudstream4.settings.desktopThemeMode import com.lagradost.cloudstream4.theme.CloudStreamTheme +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource fun main() { DesktopLog.install(AppDirs.logs) + // Plugins load in the background so a slow or broken plugin never delays the window + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { DesktopPluginManager.instance.loadAll() } application { val savedWindow = remember { SavedWindowState(desktopPreferences) } val windowState = rememberSavedWindowState(savedWindow) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt index 68843a43cab..93a3d75b5ca 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -45,7 +45,8 @@ class SettingsHomeScreen(private val open: (SearchableSettings) -> Unit) : Searc category(SettingsGeneralScreen, Res.drawable.build_24px), category(SettingsPlayerScreen, Res.drawable.play_arrow_24px), category(SettingsLayoutScreen, Res.drawable.format_paint_24px), - category(SettingsProvidersScreen, Res.drawable.extension_24px), + category(SettingsProvidersScreen, Res.drawable.plugin_lang), + category(ExtensionsScreen(open), Res.drawable.extension_24px), ) @Composable diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt new file mode 100644 index 00000000000..5747d46e73a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -0,0 +1,251 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.desktopPreferences +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.plugins.RepoPlugin +import com.lagradost.cloudstream4.plugins.Repository +import com.lagradost.cloudstream4.plugins.RepositoryClient +import com.mihon.common.preference.minusAssign +import com.mihon.common.preference.plusAssign +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import com.mihon.presentation.settings.collectAsState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** Repository URLs the user added, kept separate from the Android REPOSITORIES_KEY data store */ +private val repositories = desktopPreferences.getStringSet("desktop_repositories", emptySet()) + +private val client = RepositoryClient() + +/** Repositories and installed extensions. Only plugins built with `isCrossPlatform = true` can run on desktop */ +class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Extensions" + + @Composable + override fun getPreferences(): List { + val manager = DesktopPluginManager.instance + val repos by repositories.collectAsState() + val installed by manager.installed.collectAsState() + val errors by manager.errors.collectAsState() + val scope = rememberCoroutineScope() + val busy = remember { mutableStateMapOf() } + + val repoItems = repos.sorted().map { url -> + Preference.PreferenceItem.TextPreference( + title = url.removePrefix("https://").removePrefix("raw.githubusercontent.com/"), + subtitle = if (url == RepositoryClient.OFFICIAL_REPOSITORY) "Official repository" else url, + icon = painterResource(Res.drawable.extension_24px), + onClick = { open(RepositoryScreen(url)) }, + widget = { TextButton(onClick = { repositories -= url }) { Text("Remove") } }, + ) + } + + val addRepository = Preference.PreferenceItem.CustomPreference(title = "Add repository") { + var text by remember { mutableStateOf("") } + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("Repository URL (repo.json)") }, + ) + Button( + enabled = text.trim().startsWith("http"), + onClick = { + repositories += text.trim() + text = "" + }, + ) { Text("Add") } + } + if (RepositoryClient.OFFICIAL_REPOSITORY !in repos) { + TextButton( + modifier = Modifier.padding(horizontal = 8.dp), + onClick = { repositories += RepositoryClient.OFFICIAL_REPOSITORY }, + ) { Text("Add the official repository") } + } + } + } + + val installedItems = installed.sortedBy { it.name.lowercase() }.map { plugin -> + val error = errors[plugin.internalName] + Preference.PreferenceItem.TextPreference( + title = plugin.name, + subtitle = error?.let { "Failed to load: $it" } ?: "Version ${plugin.version}", + widget = { + BusyOr(busy[plugin.internalName] == true) { + OutlinedButton(onClick = { + busy[plugin.internalName] = true + scope.launch { + runCatching { manager.uninstall(plugin.internalName) } + busy.remove(plugin.internalName) + } + }) { Text("Uninstall") } + } + }, + ) + } + + return listOf( + Preference.PreferenceGroup(title = "Repositories", preferenceItems = repoItems + addRepository), + Preference.PreferenceGroup( + title = "Installed", + preferenceItems = installedItems.ifEmpty { + listOf(Preference.PreferenceItem.InfoPreference("No extensions installed yet. Open a repository to install some.")) + }, + ), + ) + } +} + +/** The plugins in one repository, with install, update and uninstall */ +class RepositoryScreen(private val url: String) : SearchableSettings { + private sealed interface State { + data object Loading : State + data class Failed(val message: String) : State + data class Loaded(val repository: Repository, val plugins: List) : State + } + + private var state by mutableStateOf(State.Loading) + + @Composable + override fun getTitleRes(): String = (state as? State.Loaded)?.repository?.name ?: "Repository" + + @Composable + override fun getPreferences(): List { + val manager = DesktopPluginManager.instance + val installed by manager.installed.collectAsState() + val scope = rememberCoroutineScope() + val busy = remember { mutableStateMapOf() } + val failures = remember { mutableStateMapOf() } + + LaunchedEffect(url) { + if (state is State.Loaded) return@LaunchedEffect + state = try { + val repository = client.fetchRepository(url) + State.Loaded(repository, client.fetchPlugins(repository)) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + State.Failed(t.message ?: t.toString()) + } + } + + return when (val current = state) { + State.Loading -> listOf(Preference.PreferenceItem.CustomPreference("Loading") { + CircularProgressIndicator(Modifier.padding(24.dp)) + }) + + is State.Failed -> listOf(Preference.PreferenceItem.InfoPreference("Could not load $url: ${current.message}")) + + is State.Loaded -> { + val (desktop, androidOnly) = current.plugins.sortedBy { it.name.lowercase() }.partition { it.supportsDesktop } + // Product Sans draws "(5)" as a circled digit, so counts use a separator instead of parentheses + listOfNotNull( + Preference.PreferenceGroup( + title = "Available on desktop · ${desktop.size}", + preferenceItems = desktop.map { plugin -> + val installedVersion = installed.firstOrNull { it.internalName == plugin.internalName }?.version + Preference.PreferenceItem.TextPreference( + title = plugin.name, + subtitle = failures[plugin.internalName] + ?: listOfNotNull( + plugin.description, + plugin.language?.uppercase(), + "v${plugin.version}", + "Down".takeIf { plugin.status == 0 }, + ).joinToString(" · "), + widget = { + BusyOr(busy[plugin.internalName] == true) { + fun run(action: suspend () -> Unit) { + busy[plugin.internalName] = true + failures.remove(plugin.internalName) + scope.launch { + runCatching { action() }.onFailure { + failures[plugin.internalName] = "Failed: ${it.message ?: it}" + } + busy.remove(plugin.internalName) + } + } + when { + installedVersion == null -> Button(onClick = { + run { manager.install(url, plugin) } + }) { Text("Install") } + + installedVersion < plugin.version -> Button(onClick = { + run { manager.install(url, plugin) } + }) { Text("Update") } + + else -> OutlinedButton(onClick = { + run { manager.uninstall(plugin.internalName) } + }) { Text("Uninstall") } + } + } + }, + ) + }, + ), + if (androidOnly.isEmpty()) null else Preference.PreferenceGroup( + title = "Android only · ${androidOnly.size}", + preferenceItems = listOf( + Preference.PreferenceItem.InfoPreference( + "These extensions have no desktop build yet. Their authors can publish one by setting " + + "isCrossPlatform = true in the extension's build.gradle.kts." + ) + ) + androidOnly.map { plugin -> + Preference.PreferenceItem.TextPreference( + title = plugin.name, + subtitle = plugin.description, + enabled = false, + ) + }, + ), + ) + } + } + } +} + +@Composable +private fun BusyOr(busy: Boolean, content: @Composable () -> Unit) { + if (busy) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) + } else { + content() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6f0ee55ae2..02c26cf5594 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,7 @@ navigationKtx = "2.9.8" newpipeextractor = "v0.26.3" nextlibMedia3 = "1.9.3-0.12.0" nicehttp = "0.4.18" +okhttp = "5.3.2" # Same version NiceHttp brings in overlappingpanels = "0.1.5" paletteKtx = "1.0.0" preferenceKtx = "1.2.1" @@ -134,6 +135,7 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version nextlib-media3ext = { module = "io.github.anilbeesetti:nextlib-media3ext", version.ref = "nextlibMedia3" } nextlib-mediainfo = { module = "io.github.anilbeesetti:nextlib-mediainfo", version.ref = "nextlibMedia3" } nicehttp = { module = "com.github.Blatzar:NiceHttp", version.ref = "nicehttp" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } overlappingpanels = { module = "com.github.discord:OverlappingPanels", version.ref = "overlappingpanels" } palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 8d2b9ffb776..ee43880417c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -52,6 +52,7 @@ kotlin { jvmMain.dependencies { implementation(libs.ktor.client.java) implementation(libs.kotlinx.serialization.json) + implementation(libs.okhttp) } jvmTest.dependencies { diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt new file mode 100644 index 00000000000..ec345ca9de8 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -0,0 +1,154 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream4.AppDirs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeBytes +import kotlin.io.path.writeText + +data class InstalledPlugin( + val internalName: String, + val name: String, + val version: Int, + val repositoryUrl: String, +) + +/** + * Installs plugin jars into one folder, keeps a list of them in installed.json and loads them. + * All changes go through one lock, so an install and an uninstall can never interleave. + */ +class DesktopPluginManager( + private val dir: Path, + private val client: RepositoryClient = RepositoryClient(), +) { + private val indexFile = dir.resolve("installed.json") + private val lock = Mutex() + private val loaded = mutableMapOf() + + private val _installed = MutableStateFlow(readIndex()) + val installed: StateFlow> = _installed.asStateFlow() + + /** Why a plugin failed to load, by internal name */ + private val _errors = MutableStateFlow>(emptyMap()) + val errors: StateFlow> = _errors.asStateFlow() + + fun loadedPlugin(internalName: String): LoadedPlugin? = loaded[internalName] + + private fun jarFile(internalName: String): Path = dir.resolve("$internalName.jar") + + /** Loads every installed plugin, called once at startup. A plugin that fails is skipped and its error kept */ + suspend fun loadAll() = lock.withLock { + withContext(Dispatchers.IO) { + for (plugin in _installed.value) { + if (loaded.containsKey(plugin.internalName)) continue + loadLocked(plugin.internalName) + } + } + } + + /** Downloads, verifies and loads a plugin, replacing an older version that is already installed */ + suspend fun install(repositoryUrl: String, plugin: RepoPlugin) { + val bytes = client.downloadJar(plugin) + lock.withLock { + withContext(Dispatchers.IO) { + dir.createDirectories() + loaded.remove(plugin.internalName)?.unload() + val target = jarFile(plugin.internalName) + val tmp = dir.resolve("${plugin.internalName}.jar.tmp") + tmp.writeBytes(bytes) + Files.move(tmp, target, REPLACE_EXISTING, ATOMIC_MOVE) + + val entry = InstalledPlugin(plugin.internalName, plugin.name, plugin.version, repositoryUrl) + writeIndex(_installed.value.filterNot { it.internalName == plugin.internalName } + entry) + loadLocked(plugin.internalName)?.let { throw it } + } + } + } + + suspend fun uninstall(internalName: String) = lock.withLock { + withContext(Dispatchers.IO) { + loaded.remove(internalName)?.unload() + jarFile(internalName).deleteIfExists() + writeIndex(_installed.value.filterNot { it.internalName == internalName }) + _errors.update { it - internalName } + } + } + + /** Returns the error if the plugin could not be loaded */ + private fun loadLocked(internalName: String): Throwable? { + val file = jarFile(internalName) + return try { + if (!file.exists()) throw PluginDownloadException("$file is missing, install the plugin again") + loaded[internalName] = PluginLoader.load(file) + _errors.update { it - internalName } + println("INFO DesktopPluginManager: Loaded $internalName") + null + } catch (t: Throwable) { + println("ERROR DesktopPluginManager: Could not load $internalName: $t") + t.printStackTrace() + _errors.update { it + (internalName to (t.message ?: t.toString())) } + t + } + } + + private fun readIndex(): List = runCatching { + if (!indexFile.exists()) return emptyList() + Json.parseToJsonElement(indexFile.readText()).jsonArray.map { element -> + val obj = element.jsonObject + InstalledPlugin( + internalName = obj["internalName"]!!.jsonPrimitive.content, + name = obj["name"]!!.jsonPrimitive.content, + version = obj["version"]!!.jsonPrimitive.int, + repositoryUrl = obj["repositoryUrl"]!!.jsonPrimitive.content, + ) + } + }.getOrElse { + println("ERROR DesktopPluginManager: Could not read $indexFile: $it") + emptyList() + } + + private fun writeIndex(plugins: List) { + dir.createDirectories() + val json = JsonArray(plugins.map { + JsonObject( + mapOf( + "internalName" to JsonPrimitive(it.internalName), + "name" to JsonPrimitive(it.name), + "version" to JsonPrimitive(it.version), + "repositoryUrl" to JsonPrimitive(it.repositoryUrl), + ) + ) + }) + val tmp = dir.resolve("installed.json.tmp") + tmp.writeText(json.toString()) + Files.move(tmp, indexFile, REPLACE_EXISTING, ATOMIC_MOVE) + _installed.value = plugins + } + + companion object { + /** The app's plugin manager, keeping plugins in the plugins folder next to the settings */ + val instance: DesktopPluginManager by lazy { DesktopPluginManager(AppDirs.root.resolve("plugins")) } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt new file mode 100644 index 00000000000..022210639c8 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt @@ -0,0 +1,100 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.plugins.BasePlugin +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.utils.extractorApis +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.net.URLClassLoader +import java.nio.file.Path +import java.util.jar.JarFile + +/** A plugin jar that is loaded and running */ +class LoadedPlugin internal constructor( + val file: Path, + val instance: BasePlugin, + private val classLoader: URLClassLoader, +) { + /** The providers this plugin registered when it loaded */ + val providers: List + get() = APIHolder.allProviders.withLock { APIHolder.allProviders.filter { it.sourcePlugin == instance.filename } } + + /** Runs the plugin's beforeUnload, removes everything it registered and releases the jar file */ + fun unload() { + val filename = instance.filename + runCatching { instance.beforeUnload() }.onFailure { + println("ERROR PluginLoader: beforeUnload failed for $file: $it") + } + APIHolder.apis.filter { it.sourcePlugin == filename }.forEach(APIHolder::removePluginMapping) + APIHolder.allProviders.removeAll { it.sourcePlugin == filename } + extractorApis.removeAll { it.sourcePlugin == filename } + // Closing releases the file lock, which Windows needs before the jar can be replaced or deleted + classLoader.close() + } +} + +/** + * Loads the cross-platform .jar a plugin publishes when it is built with `isCrossPlatform = true`. + * + * Unlike the Android .cs3 file, the jar has no manifest.json, so the plugin class is the one + * annotated with @CloudstreamPlugin. A manifest.json is still used if a jar has one. + */ +object PluginLoader { + private const val ANNOTATION_DESCRIPTOR = "Lcom/lagradost/cloudstream3/plugins/CloudstreamPlugin;" + + fun load(file: Path, parent: ClassLoader = PluginLoader::class.java.classLoader): LoadedPlugin { + val classLoader = URLClassLoader(arrayOf(file.toUri().toURL()), parent) + try { + val className = findPluginClassName(file) + ?: throw IllegalArgumentException("No class annotated with @CloudstreamPlugin in $file") + val pluginClass = classLoader.loadClass(className) + require(BasePlugin::class.java.isAssignableFrom(pluginClass)) { + "$className in $file does not extend BasePlugin" + } + val instance = pluginClass.getDeclaredConstructor().newInstance() as BasePlugin + instance.filename = file.toAbsolutePath().toString() + val loaded = LoadedPlugin(file, instance, classLoader) + try { + instance.load() + } catch (t: Throwable) { + loaded.unload() + throw t + } + return loaded + } catch (t: Throwable) { + runCatching { classLoader.close() } + throw t + } + } + + /** The plugin class from manifest.json, or else the top-level class carrying the annotation */ + internal fun findPluginClassName(file: Path): String? = JarFile(file.toFile()).use { jar -> + jar.getJarEntry("manifest.json")?.let { entry -> + val manifest = Json.parseToJsonElement(jar.getInputStream(entry).reader().readText()).jsonObject + manifest["pluginClassName"]?.jsonPrimitive?.content?.let { return@use it } + } + + // Scan the bytes for the annotation rather than loading every class, which would run their static initializers + val descriptor = ANNOTATION_DESCRIPTOR.toByteArray() + jar.entries().asSequence() + .filter { it.name.endsWith(".class") && !it.name.contains('$') && !it.name.startsWith("META-INF/") } + .firstOrNull { entry -> jar.getInputStream(entry).use { it.readBytes() }.contains(descriptor) } + ?.name?.removeSuffix(".class")?.replace('/', '.') + } + + private fun ByteArray.contains(other: ByteArray): Boolean { + if (other.isEmpty() || other.size > size) return false + outer@ for (i in 0..size - other.size) { + for (j in other.indices) if (this[i + j] != other[j]) continue@outer + return true + } + return false + } + + /** Keeps the annotation referenced so a rename in the library breaks this file at compile time */ + @Suppress("unused") + private val annotation = CloudstreamPlugin::class +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt new file mode 100644 index 00000000000..42886525e7f --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt @@ -0,0 +1,140 @@ +package com.lagradost.cloudstream4.plugins + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.longOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.security.MessageDigest +import java.util.concurrent.TimeUnit + +/** A repository's repo.json, see RepositoryManager.Repository in the Android app */ +data class Repository( + val url: String, + val name: String, + val description: String?, + val pluginLists: List, +) + +/** One entry in a repository's plugin list, see SitePlugin in the Android app */ +data class RepoPlugin( + val internalName: String, + val name: String, + val version: Int, + /** 0 means the provider is down, see PROVIDER_STATUS_DOWN */ + val status: Int, + val description: String?, + val authors: List, + val language: String?, + val tvTypes: List, + val iconUrl: String?, + /** Only set when the plugin is built with `isCrossPlatform = true`, which desktop needs */ + val jarUrl: String?, + /** "sha256-" */ + val jarHash: String?, + val jarFileSize: Long?, +) { + val supportsDesktop: Boolean get() = jarUrl != null +} + +class PluginDownloadException(message: String) : Exception(message) + +/** + * Downloads repositories and plugin jars. + * + * Uses OkHttp rather than java.net.http because OkHttp tries every address a host resolves to and + * races IPv6 against IPv4. The JDK clients only try the first address, so a single unreachable + * GitHub address (seen on some ISPs) made every download time out. + */ +class RepositoryClient( + private val http: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build(), +) { + suspend fun fetchRepository(url: String): Repository { + val json = Json.parseToJsonElement(getText(url)) + // Some people share the plugin list directly instead of repo.json + if (json is JsonArray) return Repository(url, url, null, listOf(url)) + val obj = json.jsonObject + return Repository( + url = url, + name = obj.string("name") ?: url, + description = obj.string("description"), + pluginLists = obj["pluginLists"]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.content }.orEmpty(), + ) + } + + /** Every plugin in all of the repository's plugin lists. A list that fails to load is skipped */ + suspend fun fetchPlugins(repository: Repository): List = repository.pluginLists.flatMap { list -> + runCatching { parsePluginList(getText(list)) } + .onFailure { println("WARNING RepositoryClient: Could not load plugin list $list: $it") } + .getOrDefault(emptyList()) + } + + /** Downloads the plugin jar and checks its size and hash against the repository */ + suspend fun downloadJar(plugin: RepoPlugin): ByteArray { + val url = plugin.jarUrl ?: throw PluginDownloadException("${plugin.name} has no desktop version") + val bytes = getBytes(url) + plugin.jarFileSize?.let { expected -> + if (bytes.size.toLong() != expected) { + throw PluginDownloadException("${plugin.name}: expected $expected bytes, got ${bytes.size}") + } + } + plugin.jarHash?.let { expected -> + val actual = "sha256-" + MessageDigest.getInstance("SHA-256").digest(bytes) + .joinToString("") { "%02x".format(it) } + if (!actual.equals(expected, ignoreCase = true)) { + throw PluginDownloadException("${plugin.name}: the download does not match the repository's hash") + } + } + return bytes + } + + private suspend fun getText(url: String): String = getBytes(url).decodeToString() + + private suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) { + http.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful) throw PluginDownloadException("HTTP ${response.code} for $url") + response.body.bytes() + } + } + + companion object { + const val OFFICIAL_REPOSITORY = "https://raw.githubusercontent.com/recloudstream/extensions/master/repo.json" + + internal fun parsePluginList(text: String): List = + Json.parseToJsonElement(text).jsonArray.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + RepoPlugin( + internalName = obj.string("internalName") ?: return@mapNotNull null, + name = obj.string("name") ?: obj.string("internalName")!!, + version = obj.primitive("version")?.intOrNull ?: 0, + status = obj.primitive("status")?.intOrNull ?: 1, + description = obj.string("description"), + authors = obj["authors"].strings(), + language = obj.string("language"), + tvTypes = obj["tvTypes"].strings(), + iconUrl = obj.string("iconUrl"), + jarUrl = obj.string("jarUrl"), + jarHash = obj.string("jarHash"), + jarFileSize = obj.primitive("jarFileSize")?.longOrNull, + ) + } + + private fun JsonObject.primitive(key: String): JsonPrimitive? = this[key] as? JsonPrimitive + + private fun JsonObject.string(key: String): String? = primitive(key)?.takeIf { it.isString }?.content + + private fun JsonElement?.strings(): List = + (this as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }.orEmpty() + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt new file mode 100644 index 00000000000..0e0e3413780 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt @@ -0,0 +1,187 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.APIHolder +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayOutputStream +import java.net.InetSocketAddress +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import kotlin.io.path.exists +import kotlin.io.path.writeBytes +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PluginTest { + private val dir: Path = Files.createTempDirectory("cs-plugins") + private var server: HttpServer? = null + + @AfterTest + fun cleanup() { + server?.stop(0) + dir.toFile().deleteRecursively() + } + + private fun classBytes(name: String): ByteArray = + javaClass.classLoader.getResourceAsStream(name.replace('.', '/') + ".class")!!.use { it.readBytes() } + + /** A jar laid out like the ones the gradle plugin publishes: classes only, no manifest.json */ + private fun pluginJar(vararg classes: String, manifest: String? = null): ByteArray { + val out = ByteArrayOutputStream() + JarOutputStream(out).use { jar -> + for (name in classes) { + jar.putNextEntry(JarEntry(name.replace('.', '/') + ".class")) + jar.write(classBytes(name)) + } + if (manifest != null) { + jar.putNextEntry(JarEntry("manifest.json")) + jar.write(manifest.toByteArray()) + } + } + return out.toByteArray() + } + + private fun writeJar(bytes: ByteArray): Path = dir.resolve("plugin-${System.nanoTime()}.jar").also { it.writeBytes(bytes) } + + private val pluginClasses = arrayOf(NotAPlugin::class.java.name, TestPlugin::class.java.name, TestProvider::class.java.name) + + @Test + fun findsThePluginClassByItsAnnotation() { + val jar = writeJar(pluginJar(*pluginClasses)) + assertEquals(TestPlugin::class.java.name, PluginLoader.findPluginClassName(jar)) + } + + @Test + fun manifestJsonWinsWhenPresent() { + val jar = writeJar(pluginJar(*pluginClasses, manifest = """{"pluginClassName":"some.Other"}""")) + assertEquals("some.Other", PluginLoader.findPluginClassName(jar)) + } + + @Test + fun jarWithoutAPluginIsRejected() { + val jar = writeJar(pluginJar(NotAPlugin::class.java.name)) + assertFailsWith { PluginLoader.load(jar) } + } + + @Test + fun loadRegistersProvidersAndUnloadRemovesThem() { + val jar = writeJar(pluginJar(*pluginClasses)) + val before = TestPlugin.unloads + + val plugin = PluginLoader.load(jar) + assertEquals(listOf("Desktop Test Provider"), plugin.providers.map { it.name }) + assertNotNull(APIHolder.getApiFromNameNull("Desktop Test Provider")) + + plugin.unload() + assertEquals(before + 1, TestPlugin.unloads) + assertTrue(plugin.providers.isEmpty()) + assertTrue(APIHolder.apis.none { it.name == "Desktop Test Provider" }) + // The class loader is closed, so Windows lets the jar be deleted + Files.delete(jar) + } + + @Test + fun parsesPluginListsWithAndWithoutDesktopJars() { + val plugins = RepositoryClient.parsePluginList( + """[ + {"internalName":"A","name":"A","version":3,"status":1,"authors":["x"],"jarUrl":"https://h/A.jar","jarHash":"sha256-00","jarFileSize":10}, + {"internalName":"B","name":"B","version":1,"status":0,"url":"https://h/B.cs3"}, + {"name":"no internal name"} + ]""" + ) + assertEquals(listOf("A", "B"), plugins.map { it.internalName }) + assertTrue(plugins[0].supportsDesktop) + assertFalse(plugins[1].supportsDesktop) + assertEquals(10L, plugins[0].jarFileSize) + } + + /** Serves repo.json, plugins.json and the jar from a local server, like a GitHub repository */ + private fun serveRepository(jar: ByteArray, jarHash: String = sha256(jar)): String { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).also { this.server = it } + val base = "http://127.0.0.1:${server.address.port}" + val files = mapOf( + "/repo.json" to """{"name":"Local test repo","manifestVersion":1,"pluginLists":["$base/plugins.json"]}""".toByteArray(), + "/plugins.json" to """[{"internalName":"TestPlugin","name":"Test plugin","version":2,"status":1, + "jarUrl":"$base/TestPlugin.jar","jarHash":"$jarHash","jarFileSize":${jar.size}}]""".toByteArray(), + "/TestPlugin.jar" to jar, + ) + server.createContext("/") { exchange -> + val body = files[exchange.requestURI.path] + exchange.sendResponseHeaders(if (body == null) 404 else 200, body?.size?.toLong() ?: -1) + body?.let { exchange.responseBody.write(it) } + exchange.close() + } + server.start() + return "$base/repo.json" + } + + private fun sha256(bytes: ByteArray) = + "sha256-" + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + + @Test + fun installFromRepositoryThenRestartThenUninstall() = runBlocking { + val repoUrl = serveRepository(pluginJar(*pluginClasses)) + val client = RepositoryClient() + val repo = client.fetchRepository(repoUrl) + assertEquals("Local test repo", repo.name) + val plugin = client.fetchPlugins(repo).single() + + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + manager.install(repoUrl, plugin) + assertEquals(listOf("TestPlugin"), manager.installed.value.map { it.internalName }) + assertNotNull(manager.loadedPlugin("TestPlugin")) + assertNotNull(APIHolder.getApiFromNameNull("Desktop Test Provider")) + + // A new manager on the same folder is what the next app start sees + manager.loadedPlugin("TestPlugin")!!.unload() + val restarted = DesktopPluginManager(dir.resolve("plugins"), client) + assertEquals(2, restarted.installed.value.single().version) + restarted.loadAll() + assertNotNull(restarted.loadedPlugin("TestPlugin")) + + restarted.uninstall("TestPlugin") + assertTrue(restarted.installed.value.isEmpty()) + assertFalse(dir.resolve("plugins/TestPlugin.jar").exists()) + assertTrue(APIHolder.apis.none { it.name == "Desktop Test Provider" }) + } + + @Test + fun downloadWithTheWrongHashIsRefused() = runBlocking { + val repoUrl = serveRepository(pluginJar(*pluginClasses), jarHash = "sha256-" + "0".repeat(64)) + val client = RepositoryClient() + val plugin = client.fetchPlugins(client.fetchRepository(repoUrl)).single() + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + + assertFailsWith { manager.install(repoUrl, plugin) } + assertTrue(manager.installed.value.isEmpty()) + } + + /** + * Installs a real plugin from the official repository and searches with it. + * Needs the internet, so it only runs with CLOUDSTREAM_ONLINE_TESTS=1. + */ + @Test + fun officialRepositoryPluginReturnsSearchResults() = runBlocking { + if (System.getenv("CLOUDSTREAM_ONLINE_TESTS") != "1") return@runBlocking println("Skipped, set CLOUDSTREAM_ONLINE_TESTS=1") + val client = RepositoryClient() + val repo = client.fetchRepository(RepositoryClient.OFFICIAL_REPOSITORY) + val plugin = client.fetchPlugins(repo).first { it.internalName == "InternetArchiveProvider" } + + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + manager.install(repo.url, plugin) + val provider = manager.loadedPlugin(plugin.internalName)!!.providers.single() + // The paged search is what the app calls, providers may implement only that one + val results = provider.search("nosferatu", 1)?.items.orEmpty() + println("${provider.name} returned ${results.size} results, first: ${results.firstOrNull()?.name}") + assertTrue(results.isNotEmpty(), "Expected search results from ${provider.name}") + manager.uninstall(plugin.internalName) + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/TestPlugin.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/TestPlugin.kt new file mode 100644 index 00000000000..4e44fc28fe3 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/TestPlugin.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.plugins.BasePlugin +import com.lagradost.cloudstream3.plugins.CloudstreamPlugin + +class TestProvider : MainAPI() { + override var name = "Desktop Test Provider" + override var mainUrl = "https://example.invalid" +} + +/** Packed into a jar by the tests, the same way a real cross-platform plugin is published */ +@CloudstreamPlugin +class TestPlugin : BasePlugin() { + override fun load() { + registerMainAPI(TestProvider()) + loads++ + } + + override fun beforeUnload() { + unloads++ + } + + companion object { + var loads = 0 + var unloads = 0 + } +} + +/** Has no annotation, so it must never be picked as the plugin class */ +class NotAPlugin From 4165c034f81f67333f1a0eff79915e75352ee245 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 01:01:08 +0530 Subject: [PATCH 05/64] Desktop: explain when a repository has no desktop extensions Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lagradost/cloudstream4/settings/ExtensionsSettings.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt index 5747d46e73a..5af01cb4ae5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -179,7 +179,12 @@ class RepositoryScreen(private val url: String) : SearchableSettings { listOfNotNull( Preference.PreferenceGroup( title = "Available on desktop · ${desktop.size}", - preferenceItems = desktop.map { plugin -> + preferenceItems = if (desktop.isEmpty()) listOf( + Preference.PreferenceItem.InfoPreference( + "Nothing in this repository runs on desktop yet. All ${androidOnly.size} of its " + + "extensions are Android only, see below." + ) + ) else desktop.map { plugin -> val installedVersion = installed.firstOrNull { it.internalName == plugin.internalName }?.version Preference.PreferenceItem.TextPreference( title = plugin.name, From 1d760a4f9dfc3f45cc697f07f1b676b0eb392992 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 10:16:13 +0530 Subject: [PATCH 06/64] Desktop: Search and Home screens Shared MVI view models for Search and Home in shared/commonMain, following COMPOSE.md, with the desktop screens built on them. - Search queries every enabled provider at once and fills each provider's row as it answers. Failures are listed separately. Rows with more pages get a More button. Ctrl+F opens Search. - Home shows one provider's home page, picked from a menu. The choice is saved under home_api_used and falls back to the first provider when that one is removed. Home refreshes when plugins finish loading or the provider settings change. - Providers are filtered by the same language and media-type settings as the Android app, and hidden search qualities are dropped. Provider calls use the same timeouts as APIRepository. - Posters load through Coil with OkHttp and a disk cache in LOCALAPPDATA, sending the provider's poster headers. Co-Authored-By: Claude Opus 5.5 (1M context) --- desktopApp/build.gradle.kts | 1 + .../com/lagradost/cloudstream4/AppShell.kt | 41 ++- .../kotlin/com/lagradost/cloudstream4/Main.kt | 3 + .../cloudstream4/browse/HomeScreen.kt | 114 ++++++++ .../lagradost/cloudstream4/browse/Posters.kt | 179 ++++++++++++ .../cloudstream4/browse/SearchScreen.kt | 123 +++++++++ .../cloudstream4/DevicePreferenceStore.kt | 5 + .../cloudstream4/home/HomeViewModel.kt | 127 +++++++++ .../cloudstream4/providers/Providers.kt | 81 ++++++ .../cloudstream4/search/SearchViewModel.kt | 151 ++++++++++ .../com/lagradost/cloudstream4/AppDirs.kt | 11 + .../plugins/DesktopPluginManager.kt | 11 + .../cloudstream4/BrowseViewModelTest.kt | 259 ++++++++++++++++++ 13 files changed, 1105 insertions(+), 1 deletion(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/home/HomeViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 1cae80b1051..36859612ce9 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -14,6 +14,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.collections.immutable) + implementation(libs.coil.network.okhttp) implementation(compose.desktop.currentOs) { // compose.desktop.currentOs imports the wrong material 2, so we exclude it exclude(group = "org.jetbrains.compose.material", module = "material") diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index e18f9d0780b..0605c4dd320 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -15,6 +15,7 @@ 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.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -31,7 +32,16 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream4.browse.HomeScreen +import com.lagradost.cloudstream4.browse.SearchScreen +import com.lagradost.cloudstream4.home.HomeAction +import com.lagradost.cloudstream4.home.HomeViewModel +import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.search.SearchViewModel +import com.lagradost.cloudstream4.settings.ExtensionsScreen import com.lagradost.cloudstream4.settings.SettingsHomeScreen +import kotlinx.coroutines.flow.combine import com.mihon.presentation.LocalBackPress import com.mihon.presentation.settings.SearchableSettings import org.jetbrains.compose.resources.DrawableResource @@ -52,7 +62,7 @@ enum class Destination( /** * Navigation for the desktop app: a rail on the left and the selected destination on the right. - * Ctrl+1 to Ctrl+4 switch destination, Escape or Alt+Left goes back. + * Ctrl+1 to Ctrl+4 switch destination, Ctrl+F opens Search, Escape or Alt+Left goes back. */ class AppNavigator { var destination by mutableStateOf(Destination.Home) @@ -71,6 +81,13 @@ class AppNavigator { settingsStack.add(page) } + /** Settings > Extensions, where plugins are installed */ + fun openExtensions() { + destination = Destination.Settings + settingsStack.clear() + settingsStack.add(ExtensionsScreen(::open)) + } + /** Returns false when there is nothing to go back to */ fun back(): Boolean { if (destination == Destination.Settings && settingsStack.isNotEmpty()) { @@ -83,6 +100,10 @@ class AppNavigator { fun onKeyEvent(event: KeyEvent): Boolean { if (event.type != KeyEventType.KeyDown) return false if (event.isCtrlPressed) { + if (event.key == Key.F) { + select(Destination.Search) + return true + } val index = listOf(Key.One, Key.Two, Key.Three, Key.Four).indexOf(event.key) if (index >= 0) { select(Destination.entries[index]) @@ -94,8 +115,24 @@ class AppNavigator { } } +/** All providers the loaded plugins registered */ +private fun loadedProviders() = APIHolder.apis.withLock { APIHolder.apis.toList() } + @Composable fun AppShell(navigator: AppNavigator) { + val settings = rememberAppSettings() + // Kept for the whole session, so switching destination keeps the results + val home = remember { HomeViewModel(::loadedProviders, settings) } + val search = remember { SearchViewModel(::loadedProviders, settings) } + // Plugins load in the background after the window opens, and the provider settings can change at any time + LaunchedEffect(home) { + combine( + DesktopPluginManager.instance.loadedNames, + settings.provider.extensionLanguages.changes(), + settings.provider.preferredMedia.changes(), + ) { _, _, _ -> }.collect { home.onAction(HomeAction.ProvidersChanged) } + } + Row(Modifier.fillMaxSize()) { NavigationRail( modifier = Modifier.fillMaxHeight(), @@ -133,6 +170,8 @@ fun AppShell(navigator: AppNavigator) { } } + Destination.Home -> HomeScreen(home, navigator::openExtensions) + Destination.Search -> SearchScreen(search, navigator::openExtensions) else -> ComingSoon(navigator.destination) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index b29bbefee18..496607a00f6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import coil3.compose.setSingletonImageLoaderFactory +import com.lagradost.cloudstream4.browse.desktopImageLoader import com.lagradost.cloudstream4.compose.LocalFocusOutlineDefault import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.app_name @@ -30,6 +32,7 @@ fun main() { @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { DesktopPluginManager.instance.loadAll() } application { + setSingletonImageLoaderFactory(::desktopImageLoader) val savedWindow = remember { SavedWindowState(desktopPreferences) } val windowState = rememberSavedWindowState(savedWindow) val navigator = remember { AppNavigator() } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt new file mode 100644 index 00000000000..6ed77fc4cc8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -0,0 +1,114 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.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.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.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.arrow_downward +import com.lagradost.cloudstream4.home.HomeAction +import com.lagradost.cloudstream4.home.HomeState +import com.lagradost.cloudstream4.home.HomeStatus +import com.lagradost.cloudstream4.home.HomeViewModel +import org.jetbrains.compose.resources.painterResource + +/** The home page of one provider, picked from a menu at the top */ +@Composable +fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit) { + val state by viewModel.state.collectAsState() + + Column(Modifier.fillMaxSize()) { + if (state.providers.isNotEmpty()) TopBar(state, viewModel::onAction) + + when (val status = state.status) { + HomeStatus.NoProviders -> Message( + "No extensions with a home page", + "Install an extension to browse it here. If you already have, check the extension languages and media types under Settings > Providers.", + ) { Button(onClick = openExtensions) { Text("Open Extensions") } } + + HomeStatus.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + is HomeStatus.Failed -> Message("${state.selected} could not be loaded", status.message) { + Button(onClick = { viewModel.onAction(HomeAction.Reload) }) { Text("Try again") } + } + + HomeStatus.Done -> if (state.rows.isEmpty()) { + Message("${state.selected} returned an empty home page") + } else { + LazyColumn(contentPadding = PaddingValues(bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + items(state.rows) { row -> + PosterRow( + title = row.name, + items = row.items, + horizontal = row.horizontalImages, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } + } + } +} + +@Composable +private fun TopBar(state: HomeState, onAction: (HomeAction) -> Unit) { + var menuOpen by remember { mutableStateOf(false) } + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box { + TextButton(onClick = { menuOpen = true }) { + Text(state.selected ?: "", style = MaterialTheme.typography.titleLarge) + Icon( + painterResource(Res.drawable.arrow_downward), + contentDescription = "Choose provider", + modifier = Modifier.padding(start = 4.dp).size(20.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + state.providers.forEach { name -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + menuOpen = false + onAction(HomeAction.SelectProvider(name)) + }, + ) + } + } + } + Box(Modifier.weight(1f)) + OutlinedButton(onClick = { onAction(HomeAction.Reload) }, enabled = state.status != HomeStatus.Loading) { + Text("Reload") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt new file mode 100644 index 00000000000..87ea424f33d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -0,0 +1,179 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.serviceLoaderEnabled +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream4.AppDirs +import okio.Path.Companion.toOkioPath + +/** + * Loads posters with OkHttp like the Android app does. OkHttp tries every address a host has, + * which the JDK client used by Coil's default network module does not. + */ +fun desktopImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + .crossfade(200) + .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.15).build() } + .diskCache { + DiskCache.Builder() + .directory(AppDirs.cache.resolve("images").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) + .build() + } + // Only this fetcher, so the Ktor one that the shared module brings in is never picked instead + .serviceLoaderEnabled(false) + .components { add(OkHttpNetworkFetcherFactory()) } + .build() + +private val verticalWidth = 140.dp +private val horizontalWidth = 240.dp + +/** A poster with its title underneath. Providers may need their own headers to serve the image */ +@Composable +fun PosterCard(item: SearchResponse, horizontal: Boolean, modifier: Modifier = Modifier) { + val context = LocalPlatformContext.current + val request = remember(item.posterUrl, item.posterHeaders) { + ImageRequest.Builder(context) + .data(item.posterUrl) + .httpHeaders(NetworkHeaders.Builder().apply { + set("User-Agent", USER_AGENT) + item.posterHeaders?.forEach { (key, value) -> set(key, value) } + }.build()) + .build() + } + Column(modifier.width(if (horizontal) horizontalWidth else verticalWidth)) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(if (horizontal) 16f / 9f else 2f / 3f) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + AsyncImage( + model = request, + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Text( + item.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + } +} + +/** + * A titled, sideways-scrolling row of posters. + * + * @param trailing shown after the last poster, for example a "More" button + */ +@Composable +fun PosterRow( + title: String, + items: List, + horizontal: Boolean, + modifier: Modifier = Modifier, + subtitle: String? = null, + loading: Boolean = false, + trailing: (@Composable () -> Unit)? = null, +) { + Column(modifier.fillMaxWidth()) { + Row( + Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + subtitle?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (loading) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } + LazyRow( + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // No keys, providers sometimes list the same item twice and duplicate keys crash the row + items(items) { PosterCard(it, horizontal) } + if (trailing != null) item { trailing() } + } + } +} + +/** The end of a row that has more pages */ +@Composable +fun MoreButton(loading: Boolean, horizontal: Boolean, onClick: () -> Unit) { + Box( + Modifier.width(if (horizontal) horizontalWidth else verticalWidth).aspectRatio(if (horizontal) 16f / 9f else 2f / 3f), + contentAlignment = Alignment.Center, + ) { + if (loading) CircularProgressIndicator(Modifier.size(24.dp)) + else OutlinedButton(onClick = onClick) { Text("More") } + } +} + +/** A centred message for empty, loading and error states */ +@Composable +fun Message(title: String, body: String? = null, action: (@Composable () -> Unit)? = null) { + Column( + Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title, style = MaterialTheme.typography.titleLarge) + body?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + action?.let { + Spacer(Modifier.size(16.dp)) + it() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt new file mode 100644 index 00000000000..977c56cc2be --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -0,0 +1,123 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.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.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.unit.dp +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.close +import com.lagradost.cloudstream4.generated.resources.search_icon +import com.lagradost.cloudstream4.generated.resources.title_search +import com.lagradost.cloudstream4.search.ProviderStatus +import com.lagradost.cloudstream4.search.SearchAction +import com.lagradost.cloudstream4.search.SearchViewModel +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** + * Searches every enabled provider. Results are grouped by provider, the same as the Android app's + * advanced search, and each provider's row appears as soon as it answers. + */ +@Composable +fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit) { + val state by viewModel.state.collectAsState() + val focus = remember { FocusRequester() } + // Opening Search puts the cursor in the box, ready to type + LaunchedEffect(Unit) { focus.requestFocus() } + + Column(Modifier.fillMaxSize()) { + OutlinedTextField( + value = state.query, + onValueChange = { viewModel.onAction(SearchAction.QueryChanged(it)) }, + placeholder = { Text(stringResource(Res.string.title_search)) }, + leadingIcon = { Icon(painterResource(Res.drawable.search_icon), contentDescription = null) }, + trailingIcon = { + if (state.query.isNotEmpty()) { + IconButton(onClick = { viewModel.onAction(SearchAction.Clear) }) { + Icon(painterResource(Res.drawable.close), contentDescription = "Clear") + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + .focusRequester(focus) + .onPreviewKeyEvent { + // Enter searches; handled here because desktop keyboards have no IME search action + if (it.type == KeyEventType.KeyDown && (it.key == Key.Enter || it.key == Key.NumPadEnter)) { + viewModel.onAction(SearchAction.Submit) + true + } else false + }, + ) + if (state.isSearching) LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) + + val found = state.results.filter { it.items.isNotEmpty() } + val failed = state.results.filter { it.status is ProviderStatus.Failed && it.items.isEmpty() } + when { + state.noProviders -> Message( + "No extensions to search with", + "Install an extension, or check the extension languages and media types under Settings > Providers.", + ) { Button(onClick = openExtensions) { Text("Open Extensions") } } + + state.searchedQuery.isEmpty() -> Message("Search all your extensions", "Type a title and press Enter.") + + found.isEmpty() && !state.isSearching -> Message( + "Nothing found for \"${state.searchedQuery}\"", + if (failed.isEmpty()) null else "${failed.size} of ${state.results.size} extensions could not be reached.", + ) + + else -> LazyColumn( + contentPadding = PaddingValues(bottom = 24.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(found) { row -> + val loading = row.status == ProviderStatus.Loading + PosterRow( + title = row.apiName, + subtitle = "${row.items.size} results", + items = row.items, + horizontal = false, + modifier = Modifier.padding(top = 8.dp), + trailing = if (row.hasNext) { + { MoreButton(loading, horizontal = false) { viewModel.onAction(SearchAction.LoadMore(row.apiName)) } } + } else null, + ) + } + if (failed.isNotEmpty()) item { + Text( + "Could not reach: " + failed.joinToString { it.apiName }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + ) + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index 168b8259028..f71545d9117 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -152,6 +152,11 @@ class ProviderPreferences(preferences: PreferenceStore) { val displayDubSub = preferences.getStringSet( "display_sub_key", defaultDub ) + + /** The provider shown on the home screen, by name. Android keeps this per account in its DataStore */ + val homeProvider = preferences.getString( + "home_api_used", "" + ) } class PlayerPreferences(preferences: PreferenceStore) { diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/home/HomeViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/home/HomeViewModel.kt new file mode 100644 index 00000000000..eac5f843575 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/home/HomeViewModel.kt @@ -0,0 +1,127 @@ +package com.lagradost.cloudstream4.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.providers.filterProviders +import com.lagradost.cloudstream4.providers.mainPageSafely +import com.lagradost.cloudstream4.providers.withoutQualities +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** One row on the home screen, a section of the provider's home page */ +data class HomeRow( + val name: String, + val items: List, + val horizontalImages: Boolean, +) + +sealed interface HomeStatus { + /** No provider with a home page is installed or enabled */ + data object NoProviders : HomeStatus + data object Loading : HomeStatus + data object Done : HomeStatus + data class Failed(val message: String) : HomeStatus +} + +data class HomeState( + /** Names of the providers that have a home page and pass the provider settings */ + val providers: List = emptyList(), + val selected: String? = null, + val rows: List = emptyList(), + val status: HomeStatus = HomeStatus.NoProviders, +) + +sealed interface HomeAction { + data class SelectProvider(val name: String) : HomeAction + data object Reload : HomeAction + /** Plugins were loaded or removed, or the provider settings changed */ + data object ProvidersChanged : HomeAction +} + +/** + * Shows the home page of one provider. The chosen provider is saved, and if it goes away + * (uninstalled, or hidden by the provider settings) the first remaining one is shown instead. + * + * @param apis all loaded providers, filtered here by the provider settings + */ +class HomeViewModel( + private val apis: () -> List, + private val settings: AppSettings, +) : ViewModel(), StateContainer by DefaultStateContainer(HomeState()), + ActionHandler { + + private var available: List = emptyList() + private var loadJob: Job? = null + + init { + refreshProviders() + } + + override fun onAction(action: HomeAction) { + when (action) { + is HomeAction.SelectProvider -> { + val api = available.firstOrNull { it.name == action.name } ?: return + settings.provider.homeProvider.set(api.name) + load(api) + } + + HomeAction.Reload -> available.firstOrNull { it.name == state.value.selected }?.let(::load) + HomeAction.ProvidersChanged -> refreshProviders() + } + } + + private fun refreshProviders() { + val provider = settings.provider + available = filterProviders( + apis(), + languages = provider.extensionLanguages.get(), + preferredMedia = provider.preferredMedia.get(), + requireMainPage = true, + ) + val names = available.map { it.name } + updateState { copy(providers = names) } + + val saved = provider.homeProvider.get() + val api = available.firstOrNull { it.name == saved } ?: available.firstOrNull() + when { + api == null -> { + loadJob?.cancel() + updateState { copy(selected = null, rows = emptyList(), status = HomeStatus.NoProviders) } + } + // Keep what is on screen if the shown provider is still there + api.name == state.value.selected && state.value.status != HomeStatus.NoProviders -> Unit + else -> load(api) + } + } + + private fun load(api: MainAPI) { + loadJob?.cancel() + updateState { copy(selected = api.name, rows = emptyList(), status = HomeStatus.Loading) } + loadJob = viewModelScope.launch { + val result = api.mainPageSafely(page = 1) + // A different provider was picked while this one was loading + if (state.value.selected != api.name) return@launch + val hidden = settings.ui.filterQuality.get() + updateState { + when (result) { + is Resource.Success -> copy( + rows = result.value.filterNotNull().flatMap { it.items } + .map { HomeRow(it.name, it.list.withoutQualities(hidden), it.isHorizontalImages) } + .filter { it.items.isNotEmpty() }, + status = HomeStatus.Done, + ) + + is Resource.Failure -> copy(status = HomeStatus.Failed(result.errorString)) + is Resource.Loading -> this + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt new file mode 100644 index 00000000000..104003544c7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt @@ -0,0 +1,81 @@ +package com.lagradost.cloudstream4.providers + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.newSearchResponseList +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout + +/** + * The providers the user wants to see, the same rules as filterProviderByPreferredMedia in the + * Android app: the provider language must be selected, unless all languages are, and it must + * support at least one preferred media type, unless none are selected. + * + * @param preferredMedia TvType ordinals as strings, as the prefer_media_type_key_2 setting stores them + */ +fun filterProviders( + apis: List, + languages: Set, + preferredMedia: Set, + requireMainPage: Boolean, +): List { + val allLanguages = languages.isEmpty() || languages.contains(AllLanguagesName) + val media = preferredMedia.mapNotNull { it.toIntOrNull() }.toSet() + return apis.filter { api -> + (allLanguages || languages.contains(api.lang)) && + (api.hasMainPage || !requireMainPage) && + (media.isEmpty() || api.supportedTypes.any { it.ordinal in media }) + } +} + +/** Drops results whose quality the user chose to hide in settings */ +fun List.withoutQualities(hidden: Set): List = + if (hidden.isEmpty()) this else filter { it.quality !in hidden } + +/* + * Calls into providers with the same limits as APIRepository in the Android app, so one slow or + * broken provider can never hang a screen. Errors come back as Resource.Failure instead of throwing. + */ + +private const val DEFAULT_TIMEOUT = 120_000L +private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT +private const val MIN_TIMEOUT = 5_000L + +internal fun providerTimeout(desired: Long?): Long = (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) + +/** Paged search, the first page is 1 */ +suspend fun MainAPI.searchSafely(query: String, page: Int): Resource { + if (query.isBlank()) return Resource.Success(newSearchResponseList(emptyList())) + return safeApiCall { + withTimeout(providerTimeout(searchTimeoutMs)) { + search(query, page) ?: throw ErrorLoadingException() + } + } +} + +/** Every home page section of this provider, the first page is 1 */ +suspend fun MainAPI.mainPageSafely(page: Int): Resource> = safeApiCall { + withTimeout(providerTimeout(getMainPageTimeoutMs)) { + lastHomepageRequest = APIHolder.unixTimeMS + val requests = mainPage.map { MainPageRequest(it.name, it.data, it.horizontalImages) } + if (sequentialMainPage) { + requests.mapIndexed { index, request -> + // Some sites block many requests at once, so these providers ask for a pause in between + if (index > 0) delay(sequentialMainPageDelay) + getMainPage(page, request) + } + } else { + requests.amap { getMainPage(page, it) } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt new file mode 100644 index 00000000000..d98f9acf492 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt @@ -0,0 +1,151 @@ +package com.lagradost.cloudstream4.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.providers.filterProviders +import com.lagradost.cloudstream4.providers.searchSafely +import com.lagradost.cloudstream4.providers.withoutQualities +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlinx.coroutines.launch + +sealed interface ProviderStatus { + data object Loading : ProviderStatus + data object Done : ProviderStatus + data class Failed(val message: String) : ProviderStatus +} + +/** The results one provider returned for the current query */ +data class ProviderResults( + val apiName: String, + val items: List = emptyList(), + val status: ProviderStatus = ProviderStatus.Loading, + val page: Int = 1, + val hasNext: Boolean = false, +) + +data class SearchState( + /** What is typed in the search box */ + val query: String = "", + /** The query the results below belong to, empty before the first search */ + val searchedQuery: String = "", + val results: List = emptyList(), + /** True when a search ran but no provider was enabled to search with */ + val noProviders: Boolean = false, +) { + val isSearching: Boolean get() = results.any { it.status == ProviderStatus.Loading } +} + +sealed interface SearchAction { + data class QueryChanged(val query: String) : SearchAction + /** Searches every enabled provider for the typed query */ + data object Submit : SearchAction + /** Fetches the next page from one provider */ + data class LoadMore(val apiName: String) : SearchAction + data object Clear : SearchAction +} + +/** + * Searches all enabled providers at once. Each provider fills in its own row as soon as it + * answers, so a slow provider never holds back the others. + * + * @param apis all loaded providers, filtered here by the provider settings on every search + */ +class SearchViewModel( + private val apis: () -> List, + private val settings: AppSettings, +) : ViewModel(), StateContainer by DefaultStateContainer(SearchState()), + ActionHandler { + + /** Parent of every request for the current query, cancelled when a new search starts */ + private var searchJob: Job = SupervisorJob() + /** Increases with every search, so a late answer for an old query is dropped */ + private var generation = 0 + private var searched: List = emptyList() + + override fun onAction(action: SearchAction) { + when (action) { + is SearchAction.QueryChanged -> updateState { copy(query = action.query) } + SearchAction.Submit -> search(state.value.query.trim()) + is SearchAction.LoadMore -> loadMore(action.apiName) + SearchAction.Clear -> { + cancel() + updateState { SearchState() } + } + } + } + + private fun cancel() { + searchJob.cancel() + generation++ + } + + private fun search(query: String) { + cancel() + if (query.isEmpty()) { + updateState { copy(searchedQuery = "", results = emptyList(), noProviders = false) } + return + } + val provider = settings.provider + searched = filterProviders( + apis(), + languages = provider.extensionLanguages.get(), + preferredMedia = provider.preferredMedia.get(), + requireMainPage = false, + ) + updateState { + copy( + searchedQuery = query, + results = searched.map { ProviderResults(it.name) }, + noProviders = searched.isEmpty(), + ) + } + searchJob = SupervisorJob(viewModelScope.coroutineContext.job) + searched.forEach { fetch(it, query, page = 1) } + } + + private fun loadMore(apiName: String) { + val current = state.value.results.firstOrNull { it.apiName == apiName } ?: return + if (!current.hasNext || current.status == ProviderStatus.Loading) return + val api = searched.firstOrNull { it.name == apiName } ?: return + updateRow(generation, apiName) { copy(status = ProviderStatus.Loading) } + fetch(api, state.value.searchedQuery, current.page + 1) + } + + private fun fetch(api: MainAPI, query: String, page: Int) { + val forGeneration = generation + viewModelScope.launch(searchJob) { + val result = api.searchSafely(query, page) + val hidden = settings.ui.filterQuality.get() + updateRow(forGeneration, api.name) { + when (result) { + is Resource.Success -> copy( + items = (if (page == 1) emptyList() else items) + result.value.items.withoutQualities(hidden), + status = ProviderStatus.Done, + page = page, + hasNext = result.value.hasNext, + ) + + is Resource.Failure -> copy(status = ProviderStatus.Failed(result.errorString)) + is Resource.Loading -> this + } + } + } + } + + private fun updateRow(forGeneration: Int, apiName: String, reducer: ProviderResults.() -> ProviderResults) { + updateState { + // Checked inside the update, so a search started meanwhile always wins + if (forGeneration != generation) return@updateState this + copy(results = results.map { if (it.apiName == apiName) it.reducer() else it }) + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt index cd9ce5a22ed..473d875dea7 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppDirs.kt @@ -28,4 +28,15 @@ object AppDirs { val settingsFile: Path get() = root.resolve("settings.json") val logs: Path get() = root.resolve("logs") + + /** Files that can be thrown away, such as poster images. On Windows this is the local, not roaming, AppData */ + val cache: Path by lazy { + val os = System.getProperty("os.name").orEmpty().lowercase() + val local = System.getenv("LOCALAPPDATA") + if (System.getProperty("cloudstream.home").isNullOrBlank() && os.contains("win") && local != null) { + Paths.get(local, APP_NAME, "cache") + } else { + root.resolve("cache") + } + } } diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt index ec345ca9de8..3f2d4ed6778 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -54,8 +54,16 @@ class DesktopPluginManager( private val _errors = MutableStateFlow>(emptyMap()) val errors: StateFlow> = _errors.asStateFlow() + /** Internal names of the plugins that are loaded, changes whenever providers are added or removed */ + private val _loadedNames = MutableStateFlow>(emptySet()) + val loadedNames: StateFlow> = _loadedNames.asStateFlow() + fun loadedPlugin(internalName: String): LoadedPlugin? = loaded[internalName] + private fun publishLoaded() { + _loadedNames.value = loaded.keys.toSet() + } + private fun jarFile(internalName: String): Path = dir.resolve("$internalName.jar") /** Loads every installed plugin, called once at startup. A plugin that fails is skipped and its error kept */ @@ -75,6 +83,7 @@ class DesktopPluginManager( withContext(Dispatchers.IO) { dir.createDirectories() loaded.remove(plugin.internalName)?.unload() + publishLoaded() val target = jarFile(plugin.internalName) val tmp = dir.resolve("${plugin.internalName}.jar.tmp") tmp.writeBytes(bytes) @@ -90,6 +99,7 @@ class DesktopPluginManager( suspend fun uninstall(internalName: String) = lock.withLock { withContext(Dispatchers.IO) { loaded.remove(internalName)?.unload() + publishLoaded() jarFile(internalName).deleteIfExists() writeIndex(_installed.value.filterNot { it.internalName == internalName }) _errors.update { it - internalName } @@ -102,6 +112,7 @@ class DesktopPluginManager( return try { if (!file.exists()) throw PluginDownloadException("$file is missing, install the plugin again") loaded[internalName] = PluginLoader.load(file) + publishLoaded() _errors.update { it - internalName } println("INFO DesktopPluginManager: Loaded $internalName") null diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt new file mode 100644 index 00000000000..62dae2d1bf3 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt @@ -0,0 +1,259 @@ +package com.lagradost.cloudstream4 + +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mainPageOf +import com.lagradost.cloudstream3.newHomePageResponse +import com.lagradost.cloudstream3.newMovieSearchResponse +import com.lagradost.cloudstream3.newSearchResponseList +import com.lagradost.cloudstream4.home.HomeAction +import com.lagradost.cloudstream4.home.HomeStatus +import com.lagradost.cloudstream4.home.HomeViewModel +import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.plugins.RepositoryClient +import com.lagradost.cloudstream4.providers.filterProviders +import com.lagradost.cloudstream4.search.ProviderStatus +import com.lagradost.cloudstream4.search.SearchAction +import com.lagradost.cloudstream4.search.SearchViewModel +import com.mihon.common.preference.JvmPreferenceStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** A provider that answers from memory, optionally waiting for [gate] first or failing */ +private class FakeProvider( + override var name: String, + override var lang: String = "en", + override val supportedTypes: Set = setOf(TvType.Movie), + override val hasMainPage: Boolean = true, + private val pages: Int = 1, + private val fail: Boolean = false, +) : MainAPI() { + override var mainUrl = "https://example.invalid" + override val mainPage = mainPageOf("popular" to "Popular", "new" to "New", "empty" to "Empty") + var gate: CompletableDeferred? = null + val searchedPages = mutableListOf() + + fun item(title: String, quality: SearchQuality? = null) = + newMovieSearchResponse(title, "https://example.invalid/$title") { this.quality = quality } + + override suspend fun search(query: String, page: Int): SearchResponseList { + gate?.await() + if (fail) throw IllegalStateException("site is down") + searchedPages += page + return newSearchResponseList( + listOf(item("$name $query $page"), item("$name cam $page", SearchQuality.Cam)), + hasNext = page < pages, + ) + } + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + gate?.await() + if (fail) throw IllegalStateException("site is down") + val items = if (request.data == "empty") emptyList() else listOf(item("$name ${request.name}")) + return newHomePageResponse(HomePageList(request.name, items, isHorizontalImages = request.data == "new")) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class BrowseViewModelTest { + private val dir = Files.createTempDirectory("cs-browse") + private val settings = AppSettings(JvmPreferenceStore(dir.resolve("settings.json"))) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + dir.toFile().deleteRecursively() + } + + /** Waits for real provider calls, which run on the worker threads */ + private fun awaitState(flow: kotlinx.coroutines.flow.StateFlow, predicate: (T) -> Boolean): T = + runBlocking { withTimeout(5_000) { flow.first(predicate) } } + + @Test + fun providerFilterFollowsLanguageMediaAndHomePageSettings() { + val en = FakeProvider("En") + val hi = FakeProvider("Hi", lang = "hi") + val anime = FakeProvider("Anime", supportedTypes = setOf(TvType.Anime)) + val noHome = FakeProvider("NoHome", hasMainPage = false) + val all = listOf(en, hi, anime, noHome) + val movie = setOf(TvType.Movie.ordinal.toString()) + + assertEquals(all, filterProviders(all, setOf(AllLanguagesName), emptySet(), requireMainPage = false)) + assertEquals(listOf(hi), filterProviders(all, setOf("hi"), emptySet(), requireMainPage = false)) + assertEquals(listOf(en, hi, noHome), filterProviders(all, setOf(AllLanguagesName), movie, requireMainPage = false)) + assertEquals(listOf(en, hi, anime), filterProviders(all, setOf(AllLanguagesName), emptySet(), requireMainPage = true)) + } + + @Test + fun searchFillsEachProviderAndKeepsFailuresSeparate() { + val good = FakeProvider("Good") + val broken = FakeProvider("Broken", fail = true) + val vm = SearchViewModel({ listOf(good, broken) }, settings) + + vm.onAction(SearchAction.QueryChanged(" dune ")) + vm.onAction(SearchAction.Submit) + val state = awaitState(vm.state) { !it.isSearching } + + assertEquals("dune", state.searchedQuery) + val (goodRow, brokenRow) = state.results + assertEquals(ProviderStatus.Done, goodRow.status) + assertEquals(listOf("Good dune 1", "Good cam 1"), goodRow.items.map { it.name }) + assertIs(brokenRow.status) + assertTrue(brokenRow.items.isEmpty()) + } + + @Test + fun hiddenQualitiesAreFilteredAndLoadMoreAppendsTheNextPage() { + settings.ui.filterQuality.set(setOf(SearchQuality.Cam)) + val paged = FakeProvider("Paged", pages = 2) + val vm = SearchViewModel({ listOf(paged) }, settings) + + vm.onAction(SearchAction.QueryChanged("x")) + vm.onAction(SearchAction.Submit) + val first = awaitState(vm.state) { !it.isSearching }.results.single() + assertEquals(listOf("Paged x 1"), first.items.map { it.name }) + assertTrue(first.hasNext) + + vm.onAction(SearchAction.LoadMore("Paged")) + val second = awaitState(vm.state) { it.results.single().page == 2 }.results.single() + assertEquals(listOf("Paged x 1", "Paged x 2"), second.items.map { it.name }) + assertEquals(false, second.hasNext) + + // Nothing more to load, so no request is made + vm.onAction(SearchAction.LoadMore("Paged")) + assertEquals(listOf(1, 2), paged.searchedPages) + } + + @Test + fun aNewSearchDropsLateAnswersFromTheOldOne() { + val slow = FakeProvider("Slow").apply { gate = CompletableDeferred() } + val vm = SearchViewModel({ listOf(slow) }, settings) + + vm.onAction(SearchAction.QueryChanged("old")) + vm.onAction(SearchAction.Submit) + val oldGate = slow.gate!! + slow.gate = null + vm.onAction(SearchAction.QueryChanged("new")) + vm.onAction(SearchAction.Submit) + oldGate.complete(Unit) + + val state = awaitState(vm.state) { !it.isSearching } + assertEquals(listOf("Slow new 1", "Slow cam 1"), state.results.single().items.map { it.name }) + } + + @Test + fun searchWithNoEnabledProvidersSaysSo() { + settings.provider.extensionLanguages.set(setOf("fr")) + val vm = SearchViewModel({ listOf(FakeProvider("En")) }, settings) + vm.onAction(SearchAction.QueryChanged("x")) + vm.onAction(SearchAction.Submit) + assertTrue(vm.state.value.noProviders) + } + + @Test + fun homeShowsTheFirstProviderAndDropsEmptyRows() { + val vm = HomeViewModel({ listOf(FakeProvider("A"), FakeProvider("B")) }, settings) + val state = awaitState(vm.state) { it.status == HomeStatus.Done } + + assertEquals(listOf("A", "B"), state.providers) + assertEquals("A", state.selected) + assertEquals(listOf("Popular", "New"), state.rows.map { it.name }) + assertEquals(listOf(false, true), state.rows.map { it.horizontalImages }) + } + + @Test + fun theChosenHomeProviderIsSavedAndUsedNextTime() { + val providers = listOf(FakeProvider("A"), FakeProvider("B")) + val vm = HomeViewModel({ providers }, settings) + vm.onAction(HomeAction.SelectProvider("B")) + awaitState(vm.state) { it.selected == "B" && it.status == HomeStatus.Done } + + val restarted = AppSettings(JvmPreferenceStore(dir.resolve("settings.json"))) + val next = HomeViewModel({ providers }, restarted) + assertEquals("B", awaitState(next.state) { it.status == HomeStatus.Done }.selected) + } + + @Test + fun homeWaitsForProvidersThenFallsBackWhenTheShownOneIsRemoved() { + var providers = emptyList() + val vm = HomeViewModel({ providers }, settings) + assertEquals(HomeStatus.NoProviders, vm.state.value.status) + + // Plugins finish loading after the screen opened + providers = listOf(FakeProvider("A"), FakeProvider("B")) + vm.onAction(HomeAction.SelectProvider("B")) // not known yet, ignored + vm.onAction(HomeAction.ProvidersChanged) + awaitState(vm.state) { it.selected == "A" && it.status == HomeStatus.Done } + vm.onAction(HomeAction.SelectProvider("B")) + awaitState(vm.state) { it.selected == "B" && it.status == HomeStatus.Done } + + providers = providers.take(1) + vm.onAction(HomeAction.ProvidersChanged) + assertEquals("A", awaitState(vm.state) { it.status == HomeStatus.Done && it.selected == "A" }.selected) + } + + /** + * Installs a real plugin from the official repository, then browses its home page and searches it. + * Needs the internet, so it only runs with CLOUDSTREAM_ONLINE_TESTS=1. + */ + @Test + fun officialPluginWorksInHomeAndSearch() { + if (System.getenv("CLOUDSTREAM_ONLINE_TESTS") != "1") return println("Skipped, set CLOUDSTREAM_ONLINE_TESTS=1") + val client = RepositoryClient() + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + val plugin = runBlocking { + val repo = client.fetchRepository(RepositoryClient.OFFICIAL_REPOSITORY) + client.fetchPlugins(repo).first { it.internalName == "InternetArchiveProvider" }.also { + manager.install(repo.url, it) + } + } + val providers = manager.loadedPlugin(plugin.internalName)!!.providers + try { + val home = HomeViewModel({ providers }, settings) + val homeState = runBlocking { withTimeout(60_000) { home.state.first { it.status !is HomeStatus.Loading } } } + println("Home ${homeState.selected}: ${homeState.status}, rows ${homeState.rows.map { "${it.name} (${it.items.size})" }}") + assertEquals(HomeStatus.Done, homeState.status) + assertTrue(homeState.rows.isNotEmpty()) + + val search = SearchViewModel({ providers }, settings) + search.onAction(SearchAction.QueryChanged("nosferatu")) + search.onAction(SearchAction.Submit) + val row = runBlocking { withTimeout(60_000) { search.state.first { !it.isSearching } } }.results.single() + println("Search ${row.apiName}: ${row.status}, ${row.items.size} results, first ${row.items.firstOrNull()?.name}") + assertTrue(row.items.isNotEmpty()) + } finally { + runBlocking { manager.uninstall(plugin.internalName) } + } + } + + @Test + fun aFailingHomePageShowsTheErrorAndCanBeRetried() { + val vm = HomeViewModel({ listOf(FakeProvider("Down", fail = true)) }, settings) + assertIs(awaitState(vm.state) { it.status is HomeStatus.Failed }.status) + vm.onAction(HomeAction.Reload) + assertIs(awaitState(vm.state) { it.status is HomeStatus.Failed }.status) + } +} From fa6043320e62793b32f2db6dde571b0f83bf5880 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 11:21:04 +0530 Subject: [PATCH 07/64] Desktop: detail page and video playback with mpv Clicking a poster opens its details. Play finds the links and plays the best one in the app window with libmpv. - Player: libmpv over JNA, embedded in the window. libVLC was tried first but can only send a Referer and User-Agent, while many extensions need other headers too. mpv sends every header the extension gives on every request, playlists and segments included. Plays MP4, HLS and DASH with hardware decoding. mpv's own controls handle seeking. - libmpv-2.dll comes from a pinned shinchiro build, downloaded at build time, checked against its SHA-256 and installed with the app together with a third-party notice and the GPL text. - Shared DetailViewModel: movie, series and anime pages, seasons grouped with their names, sub/dub choice, torrents marked unsupported. - Shared LinksViewModel: links stream in, best quality first. The best link plays once all are in, or 6 s after the first if extractors are slow. A link that fails to play moves on to the next. Torrent and DRM links are skipped and counted. - Player screen: a bar with Source, Subtitles and Audio pickers that open beside the video, since nothing can be drawn over the native video window. Extension subtitles are downloaded with their own headers. Space, arrows, F and Escape work, inside the video too. - Navigation: details and player open as pages on top of Home or Search, each owning its view models. Full screen hides the rail; Escape leaves full screen, then the page. Co-Authored-By: Claude Opus 5.5 (1M context) --- desktopApp/build.gradle.kts | 69 +++++ .../common/THIRD-PARTY-NOTICES.txt | 26 ++ .../com/lagradost/cloudstream4/AppShell.kt | 108 ++++++- .../kotlin/com/lagradost/cloudstream4/Main.kt | 20 ++ .../cloudstream4/SavedWindowState.kt | 2 + .../cloudstream4/browse/DetailScreen.kt | 201 ++++++++++++ .../cloudstream4/browse/HomeScreen.kt | 3 +- .../lagradost/cloudstream4/browse/Posters.kt | 53 ++-- .../cloudstream4/browse/SearchScreen.kt | 3 +- .../com/lagradost/cloudstream4/player/Mpv.kt | 288 ++++++++++++++++++ .../cloudstream4/player/PlayerScreen.kt | 279 +++++++++++++++++ .../cloudstream4/player/Subtitles.kt | 46 +++ gradle/libs.versions.toml | 2 + .../cloudstream4/detail/DetailViewModel.kt | 165 ++++++++++ .../cloudstream4/player/LinksViewModel.kt | 134 ++++++++ .../cloudstream4/providers/Providers.kt | 48 +++ .../cloudstream4/PlaybackViewModelTest.kt | 218 +++++++++++++ 17 files changed, 1634 insertions(+), 31 deletions(-) create mode 100644 desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 36859612ce9..13cecdacc46 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,4 +1,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import java.net.URI +import java.security.MessageDigest +import javax.inject.Inject plugins { alias(libs.plugins.kotlin.multiplatform) @@ -15,6 +18,8 @@ kotlin { implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.collections.immutable) implementation(libs.coil.network.okhttp) + implementation(libs.jna) + implementation(libs.okhttp) implementation(compose.desktop.currentOs) { // compose.desktop.currentOs imports the wrong material 2, so we exclude it exclude(group = "org.jetbrains.compose.material", module = "material") @@ -30,12 +35,76 @@ tasks.withType { jvmArgs("--enable-native-access=ALL-UNNAMED") } +/** + * Downloads the pinned libmpv build that plays video and unpacks libmpv-2.dll from it. + * Both downloads are checked against their SHA-256 and kept in the build folder between builds. + */ +abstract class PrepareLibmpv : DefaultTask() { + @get:Input abstract val archiveUrl: Property + @get:Input abstract val archiveSha256: Property + @get:Input abstract val sevenZipUrl: Property + @get:Input abstract val sevenZipSha256: Property + @get:Internal abstract val downloadDir: DirectoryProperty + @get:OutputDirectory abstract val outputDir: DirectoryProperty + @get:Inject abstract val exec: ExecOperations + + @TaskAction + fun prepare() { + val downloads = downloadDir.get().asFile.apply { mkdirs() } + val archive = fetch(archiveUrl.get(), archiveSha256.get(), downloads.resolve("mpv-dev.7z")) + val sevenZip = fetch(sevenZipUrl.get(), sevenZipSha256.get(), downloads.resolve("7zr.exe")) + val out = outputDir.get().asFile.apply { deleteRecursively(); mkdirs() } + exec.exec { commandLine(sevenZip.absolutePath, "x", "-y", "-o${out.absolutePath}", archive.absolutePath, "libmpv-2.dll") } + } + + private fun fetch(url: String, sha256: String, file: File): File { + if (!file.exists() || file.sha256() != sha256) { + logger.lifecycle("Downloading $url") + URI(url).toURL().openStream().use { input -> file.outputStream().use { input.copyTo(it) } } + } + val actual = file.sha256() + if (actual != sha256) { + file.delete() + throw GradleException("$url has SHA-256 $actual, expected $sha256") + } + return file + } + + private fun File.sha256(): String = + MessageDigest.getInstance("SHA-256").digest(readBytes()).joinToString("") { "%02x".format(it) } +} + +val prepareLibmpv by tasks.registering(PrepareLibmpv::class) { + // shinchiro keeps builds for a few months only: before this one is removed, update to a newer build or mirror it + archiveUrl = "https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20261002/mpv-dev-x86_64-20261002-git-3186d369f9.7z" + archiveSha256 = "d873450cc1a7f881a8a10c33936d9555ad18a3809d827b9dbea55ba55caebcf9" + // The archive uses the BCJ2 filter, which only 7-Zip itself unpacks. 7zr.exe is its small standalone version + sevenZipUrl = "https://www.7-zip.org/a/7zr.exe" + sevenZipSha256 = "ad4c82fadcbdf93c03b4fc440f300509c7d60c5c2f4d183e35d9d70d6957037d" + downloadDir = layout.buildDirectory.dir("libmpv-download") + // In the windows folder of the app resources, so it is only bundled on Windows + outputDir = layout.buildDirectory.dir("libmpv/windows") + onlyIf { System.getProperty("os.name").startsWith("Windows") } +} + +/** Files installed next to the app: the notices and licence from src/app-resources, and libmpv on Windows */ +val assembleAppResources by tasks.registering(Sync::class) { + from("src/app-resources") + from(rootProject.file("LICENSE")) { into("common"); rename { "LICENSE.txt" } } + from(prepareLibmpv.map { it.outputDir.get().asFile.parentFile }) + into(layout.buildDirectory.dir("app-resources")) +} + +tasks.matching { it.name == "prepareAppResources" }.configureEach { dependsOn(assembleAppResources) } + compose.desktop { application { mainClass = "com.lagradost.cloudstream4.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "CloudStream" + // Holds libmpv-2.dll and the notices, found at runtime through the compose.application.resources.dir property + appResourcesRootDir = layout.buildDirectory.dir("app-resources") // MSI requires MAJOR.MINOR.BUILD with numbers only, which versionName already is packageVersion = libs.versions.versionName.get() diff --git a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt new file mode 100644 index 00000000000..c135b8593ec --- /dev/null +++ b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,26 @@ +CloudStream for desktop includes third-party software +===================================================== + +CloudStream is free software under the GNU General Public License version 3, +see LICENSE.txt next to this file. + +libmpv (libmpv-2.dll) +--------------------- +Plays video. libmpv-2.dll is built from mpv and the libraries it is compiled +with, among them FFmpeg, libass, libplacebo, HarfBuzz, FreeType, dav1d and +zlib. This build includes GPL-licensed parts, so libmpv-2.dll as a whole is +distributed under the GNU General Public License, version 2 or later, which +allows distribution under version 3 (LICENSE.txt). + +Build: mpv-dev-x86_64-20261002-git-3186d369f9 from +https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261002 +(mpv commit 3186d369f9). The build scripts and the exact versions of every +library are in that repository at the same tag. + +Source code: + mpv https://github.com/mpv-player/mpv + Build scripts https://github.com/shinchiro/mpv-winbuild-cmake + FFmpeg https://ffmpeg.org/download.html + +The complete corresponding source code of libmpv-2.dll is also published +with each CloudStream desktop release on GitHub, next to the installer. diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 0605c4dd320..c3bdeadc881 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -33,7 +33,17 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.generated.resources.* import com.lagradost.cloudstream3.APIHolder +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.lagradost.cloudstream4.browse.DetailScreen import com.lagradost.cloudstream4.browse.HomeScreen +import com.lagradost.cloudstream4.browse.Message +import com.lagradost.cloudstream4.detail.DetailViewModel +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.player.LinksViewModel +import com.lagradost.cloudstream4.player.PlayerScreen import com.lagradost.cloudstream4.browse.SearchScreen import com.lagradost.cloudstream4.home.HomeAction import com.lagradost.cloudstream4.home.HomeViewModel @@ -60,8 +70,17 @@ enum class Destination( Settings(Res.string.title_settings, Res.drawable.settings_icon_outline, Res.drawable.settings_icon_filled), } +/** A page opened on top of a destination. It owns the view models made for it, cleared when it closes */ +sealed class Page : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + + class Details(val apiName: String, val url: String) : Page() + class Player(val request: PlayRequest) : Page() +} + /** - * Navigation for the desktop app: a rail on the left and the selected destination on the right. + * Navigation for the desktop app: a rail on the left and the selected destination on the right, + * with pages such as details and the player opened on top. * Ctrl+1 to Ctrl+4 switch destination, Ctrl+F opens Search, Escape or Alt+Left goes back. */ class AppNavigator { @@ -71,18 +90,48 @@ class AppNavigator { /** Settings pages opened on top of the settings home, last is shown */ val settingsStack = mutableStateListOf() + /** Details and player pages, last is shown */ + val pages = mutableStateListOf() + + /** The player fills the screen, without the rail or the window frame */ + var fullscreen by mutableStateOf(false) + fun select(destination: Destination) { + closePages() // Selecting Settings again returns to the settings home if (destination == Destination.Settings && this.destination == Destination.Settings) settingsStack.clear() this.destination = destination } + fun openDetails(apiName: String, url: String) { + pages.add(Page.Details(apiName, url)) + } + + fun play(request: PlayRequest) { + pages.add(Page.Player(request)) + } + + fun toggleFullscreen() { + fullscreen = !fullscreen && pages.lastOrNull() is Page.Player + } + + private fun popPage() { + val page = pages.removeAt(pages.lastIndex) + if (page is Page.Player) fullscreen = false + page.viewModelStore.clear() + } + + private fun closePages() { + while (pages.isNotEmpty()) popPage() + } + fun open(page: SearchableSettings) { settingsStack.add(page) } /** Settings > Extensions, where plugins are installed */ fun openExtensions() { + closePages() destination = Destination.Settings settingsStack.clear() settingsStack.add(ExtensionsScreen(::open)) @@ -90,6 +139,14 @@ class AppNavigator { /** Returns false when there is nothing to go back to */ fun back(): Boolean { + if (fullscreen) { + fullscreen = false + return true + } + if (pages.isNotEmpty()) { + popPage() + return true + } if (destination == Destination.Settings && settingsStack.isNotEmpty()) { settingsStack.removeAt(settingsStack.lastIndex) return true @@ -133,8 +190,10 @@ fun AppShell(navigator: AppNavigator) { ) { _, _, _ -> }.collect { home.onAction(HomeAction.ProvidersChanged) } } + val page = navigator.pages.lastOrNull() + val hideRail = page is Page.Player && navigator.fullscreen Row(Modifier.fillMaxSize()) { - NavigationRail( + if (!hideRail) NavigationRail( modifier = Modifier.fillMaxHeight(), containerColor = MaterialTheme.colorScheme.background, ) { @@ -158,26 +217,59 @@ fun AppShell(navigator: AppNavigator) { } } } - VerticalDivider() + if (!hideRail) VerticalDivider() Box(Modifier.weight(1f).fillMaxHeight()) { - when (navigator.destination) { + if (page != null) { + CompositionLocalProvider(LocalViewModelStoreOwner provides page) { + PageContent(page, navigator) + } + } else when (navigator.destination) { Destination.Settings -> { - val page = navigator.settingsStack.lastOrNull() + val settingsPage = navigator.settingsStack.lastOrNull() ?: remember(navigator) { SettingsHomeScreen(navigator::open) } val back: (() -> Unit)? = if (navigator.settingsStack.isEmpty()) null else ({ navigator.back() }) CompositionLocalProvider(LocalBackPress provides back) { - page.Content() + settingsPage.Content() } } - Destination.Home -> HomeScreen(home, navigator::openExtensions) - Destination.Search -> SearchScreen(search, navigator::openExtensions) + Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails) + Destination.Search -> SearchScreen(search, navigator::openExtensions, navigator::openDetails) else -> ComingSoon(navigator.destination) } } } } +@Composable +private fun PageContent(page: Page, navigator: AppNavigator) { + val apiName = when (page) { + is Page.Details -> page.apiName + is Page.Player -> page.request.apiName + } + // The provider can be gone if its plugin was uninstalled while the page was open + val api = remember(apiName) { APIHolder.getApiFromNameNull(apiName) } + if (api == null) { + Message("$apiName is not loaded", "Its extension may have been uninstalled.") + return + } + when (page) { + is Page.Details -> DetailScreen( + viewModel = viewModel { DetailViewModel(api, page.url) }, + onBack = { navigator.back() }, + onPlay = navigator::play, + onOpen = navigator::openDetails, + ) + + is Page.Player -> PlayerScreen( + viewModel = viewModel { LinksViewModel(api, page.request) }, + fullscreen = navigator.fullscreen, + onBack = { navigator.back() }, + onToggleFullscreen = navigator::toggleFullscreen, + ) + } +} + /** Placeholder for the destinations built in later phases */ @Composable private fun ComingSoon(destination: Destination) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 496607a00f6..86a2d3f1568 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -6,7 +6,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import coil3.compose.setSingletonImageLoaderFactory @@ -47,6 +52,21 @@ fun main() { onPreviewKeyEvent = navigator::onKeyEvent, ) { MainContent(navigator) + FullscreenEffect(navigator, windowState) + } + } +} + +/** Puts the window in full screen while the player asks for it, then back to how it was */ +@Composable +private fun FullscreenEffect(navigator: AppNavigator, windowState: WindowState) { + var before by remember { mutableStateOf(windowState.placement) } + LaunchedEffect(navigator.fullscreen) { + if (navigator.fullscreen) { + before = windowState.placement + windowState.placement = WindowPlacement.Fullscreen + } else if (windowState.placement == WindowPlacement.Fullscreen) { + windowState.placement = before } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt index 6a56abbdea6..eedea176dc5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/SavedWindowState.kt @@ -34,6 +34,8 @@ class SavedWindowState(preferences: PreferenceStore) { } fun save(state: WindowState) { + // Full screen is only for watching, the window opens as it was before that + if (state.placement == WindowPlacement.Fullscreen) return maximized.set(state.placement == WindowPlacement.Maximized) // Only remember the floating size, otherwise un-maximizing would keep the window full screen if (state.placement != WindowPlacement.Floating) return diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt new file mode 100644 index 00000000000..ebb956eb721 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -0,0 +1,201 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream4.detail.DetailAction +import com.lagradost.cloudstream4.detail.DetailState +import com.lagradost.cloudstream4.detail.DetailStatus +import com.lagradost.cloudstream4.detail.DetailViewModel +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.arrow_back +import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import org.jetbrains.compose.resources.painterResource + +/** The details of a movie or series, with its episodes and similar titles */ +@Composable +fun DetailScreen( + viewModel: DetailViewModel, + onBack: () -> Unit, + onPlay: (PlayRequest) -> Unit, + onOpen: (apiName: String, url: String) -> Unit, +) { + val state by viewModel.state.collectAsState() + Column(Modifier.fillMaxSize()) { + IconButton(onClick = onBack, modifier = Modifier.padding(8.dp)) { + Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back") + } + when (val status = state.status) { + DetailStatus.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + is DetailStatus.Failed -> Message("Could not load this title", status.message) { + Button(onClick = { viewModel.onAction(DetailAction.Retry) }) { Text("Try again") } + } + + DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen) + } + } +} + +@Composable +private fun Details( + state: DetailState, + viewModel: DetailViewModel, + onPlay: (PlayRequest) -> Unit, + onOpen: (apiName: String, url: String) -> Unit, +) { + val response = state.response ?: return + val season = state.seasons.getOrNull(state.selectedSeason) + LazyColumn(contentPadding = PaddingValues(bottom = 32.dp), modifier = Modifier.fillMaxSize()) { + item { Header(response, state, onPlay = { viewModel.playRequest()?.let(onPlay) }) } + + if (state.dubs.size > 1 || state.seasons.size > 1) item { + Choices(state, viewModel::onAction) + } + + if (season != null) { + item { + Text( + "${season.label} · ${season.episodes.size} episodes", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), + ) + } + items(season.episodes) { episode -> + EpisodeRow(episode) { viewModel.playRequest(episode)?.let(onPlay) } + HorizontalDivider(Modifier.padding(horizontal = 24.dp)) + } + } + + val recommendations = response.recommendations.orEmpty() + if (recommendations.isNotEmpty()) item { + PosterRow( + title = "More like this", + items = recommendations, + horizontal = false, + modifier = Modifier.padding(top = 24.dp), + onItemClick = { onOpen(it.apiName, it.url) }, + ) + } + } +} + +@Composable +private fun Header(response: LoadResponse, state: DetailState, onPlay: () -> Unit) { + Row(Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp)) { + RemoteImage( + response.posterUrl, + response.posterHeaders, + contentDescription = response.name, + modifier = Modifier.width(200.dp).height(300.dp), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(response.name, style = MaterialTheme.typography.headlineMedium) + val facts = listOfNotNull( + response.year?.toString(), + response.type.name, + response.duration?.let { "$it min" }, + response.contentRating, + response.apiName, + ) + Text(facts.joinToString(" · "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + response.tags?.takeIf { it.isNotEmpty() }?.let { + Text(it.joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + response.plot?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) + } + when { + state.unsupported != null -> Text(state.unsupported.orEmpty(), color = MaterialTheme.colorScheme.error) + state.movieData != null -> Button(onClick = onPlay, modifier = Modifier.padding(top = 8.dp)) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + Text("Play", modifier = Modifier.padding(start = 8.dp)) + } + state.seasons.isEmpty() -> Text("No episodes yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { + Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (state.dubs.size > 1) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + state.dubs.forEach { dub -> + FilterChip( + selected = dub == state.selectedDub, + onClick = { onAction(DetailAction.SelectDub(dub)) }, + label = { Text(if (dub == DubStatus.None) "Default" else dub.name) }, + ) + } + } + if (state.seasons.size > 1) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + state.seasons.forEachIndexed { index, season -> + FilterChip( + selected = index == state.selectedSeason, + onClick = { onAction(DetailAction.SelectSeason(index)) }, + label = { Text(season.label) }, + ) + } + } + } +} + +@Composable +private fun EpisodeRow(episode: Episode, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (episode.posterUrl != null) { + RemoteImage(episode.posterUrl, null, contentDescription = null, modifier = Modifier.width(160.dp).height(90.dp)) + } else { + Box( + Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) } + } + Column(Modifier.weight(1f)) { + Text(DetailViewModel.episodeLabel(episode), style = MaterialTheme.typography.titleSmall) + episode.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + episode.runTime?.let { Text("$it min", style = MaterialTheme.typography.bodySmall) } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index 6ed77fc4cc8..606c3a067a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -39,7 +39,7 @@ import org.jetbrains.compose.resources.painterResource /** The home page of one provider, picked from a menu at the top */ @Composable -fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit) { +fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit, openDetails: (apiName: String, url: String) -> Unit) { val state by viewModel.state.collectAsState() Column(Modifier.fillMaxSize()) { @@ -69,6 +69,7 @@ fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit) { items = row.items, horizontal = row.horizontalImages, modifier = Modifier.padding(top = 8.dp), + onItemClick = { openDetails(it.apiName, it.url) }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt index 87ea424f33d..238bfa4a181 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream4.browse 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 @@ -66,34 +67,43 @@ fun desktopImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Buil private val verticalWidth = 140.dp private val horizontalWidth = 240.dp -/** A poster with its title underneath. Providers may need their own headers to serve the image */ +/** An image from a provider, which may need its own headers to serve it */ @Composable -fun PosterCard(item: SearchResponse, horizontal: Boolean, modifier: Modifier = Modifier) { +fun RemoteImage(url: String?, headers: Map?, contentDescription: String?, modifier: Modifier = Modifier) { val context = LocalPlatformContext.current - val request = remember(item.posterUrl, item.posterHeaders) { + val request = remember(url, headers) { ImageRequest.Builder(context) - .data(item.posterUrl) + .data(url) .httpHeaders(NetworkHeaders.Builder().apply { set("User-Agent", USER_AGENT) - item.posterHeaders?.forEach { (key, value) -> set(key, value) } + headers?.forEach { (key, value) -> set(key, value) } }.build()) .build() } - Column(modifier.width(if (horizontal) horizontalWidth else verticalWidth)) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(if (horizontal) 16f / 9f else 2f / 3f) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - AsyncImage( - model = request, - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } + Box(modifier.clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) { + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } +} + +/** A poster with its title underneath */ +@Composable +fun PosterCard(item: SearchResponse, horizontal: Boolean, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) { + Column( + modifier + .width(if (horizontal) horizontalWidth else verticalWidth) + .then(if (onClick != null) Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick) else Modifier) + ) { + RemoteImage( + item.posterUrl, + item.posterHeaders, + contentDescription = item.name, + modifier = Modifier.fillMaxWidth().aspectRatio(if (horizontal) 16f / 9f else 2f / 3f), + ) Text( item.name, style = MaterialTheme.typography.bodyMedium, @@ -117,6 +127,7 @@ fun PosterRow( modifier: Modifier = Modifier, subtitle: String? = null, loading: Boolean = false, + onItemClick: ((SearchResponse) -> Unit)? = null, trailing: (@Composable () -> Unit)? = null, ) { Column(modifier.fillMaxWidth()) { @@ -136,7 +147,7 @@ fun PosterRow( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // No keys, providers sometimes list the same item twice and duplicate keys crash the row - items(items) { PosterCard(it, horizontal) } + items(items) { item -> PosterCard(item, horizontal, onClick = onItemClick?.let { { it(item) } }) } if (trailing != null) item { trailing() } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt index 977c56cc2be..4b7e9f0bc7b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -43,7 +43,7 @@ import org.jetbrains.compose.resources.stringResource * advanced search, and each provider's row appears as soon as it answers. */ @Composable -fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit) { +fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDetails: (apiName: String, url: String) -> Unit) { val state by viewModel.state.collectAsState() val focus = remember { FocusRequester() } // Opening Search puts the cursor in the box, ready to type @@ -104,6 +104,7 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit) { items = row.items, horizontal = false, modifier = Modifier.padding(top = 8.dp), + onItemClick = { openDetails(it.apiName, it.url) }, trailing = if (row.hasNext) { { MoreButton(loading, horizontal = false) { viewModel.onAction(SearchAction.LoadMore(row.apiName)) } } } else null, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt new file mode 100644 index 00000000000..c9148f0f131 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -0,0 +1,288 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import java.awt.Canvas +import java.awt.Color +import java.io.File +import java.util.concurrent.TimeUnit +import java.util.concurrent.CountDownLatch + +/** The libmpv functions the player uses, see mpv/client.h */ +@Suppress("FunctionName") +internal interface MpvLibrary : Library { + fun mpv_create(): Pointer? + fun mpv_initialize(ctx: Pointer): Int + fun mpv_set_option_string(ctx: Pointer, name: String, value: String): Int + fun mpv_get_property_string(ctx: Pointer, name: String): Pointer? + fun mpv_command(ctx: Pointer, args: Array): Int + fun mpv_free(data: Pointer) + fun mpv_error_string(error: Int): String + fun mpv_wait_event(ctx: Pointer, timeout: Double): Pointer + fun mpv_terminate_destroy(ctx: Pointer) +} + +/** Finds and loads libmpv-2.dll once */ +object Mpv { + private const val FILE = "libmpv-2.dll" + + /** Why libmpv could not be loaded, or null when it loaded */ + var loadError: String? = null + private set + + internal val library: MpvLibrary? by lazy { + // The installed app keeps it in its resources folder. When run from Gradle it is in the build folder + val candidates = listOfNotNull( + System.getProperty("compose.application.resources.dir")?.let { File(it, FILE) }, + File("build/libmpv/windows", FILE), + File("desktopApp/build/libmpv/windows", FILE), + ) + val file = candidates.firstOrNull { it.isFile } + if (file == null) { + loadError = "$FILE was not found. Looked in: ${candidates.joinToString { it.absolutePath }}" + println("ERROR Mpv: $loadError") + return@lazy null + } + runCatching { + Native.load(file.absolutePath, MpvLibrary::class.java, mapOf(Library.OPTION_STRING_ENCODING to "UTF-8")) + }.onSuccess { + println("INFO Mpv: Loaded ${file.absolutePath}") + }.onFailure { + loadError = "$FILE could not be loaded: ${it.message}" + println("ERROR Mpv: $loadError") + }.getOrNull() + } +} + +data class MpvTrack( + val id: Int, + /** "video", "audio" or "sub" */ + val type: String, + val title: String?, + val lang: String?, + val selected: Boolean, + val external: Boolean, +) { + val label: String get() = listOfNotNull(title, lang).distinct().joinToString(" · ").ifEmpty { "Track $id" } +} + +data class MpvStatus( + val position: Double = 0.0, + val duration: Double = 0.0, + val paused: Boolean = false, + val buffering: Boolean = false, + val tracks: List = emptyList(), +) + +sealed interface MpvEvent { + /** Escape or Backspace was pressed inside the video */ + data object Back : MpvEvent + /** F or a double click inside the video */ + data object ToggleFullscreen : MpvEvent + data object Ended : MpvEvent + /** The file could not be played, for example a refused or broken link */ + data class Failed(val url: String, val message: String) : MpvEvent +} + +/** + * One mpv player drawing into [canvas]. It starts when the canvas is shown and shuts down when the + * canvas is removed, before its window is destroyed. Calls made before it started are run then. + * mpv's own on-screen controller gives the seek bar and buttons, and its default keys work. + */ +class MpvPlayer internal constructor(private val lib: MpvLibrary) { + private val ctx: Pointer = lib.mpv_create() ?: error("mpv_create failed") + private var started = false + private var closed = false + private val pending = mutableListOf<() -> Unit>() + private var eventThread: Thread? = null + private val stopped = CountDownLatch(1) + /** The url being played, so a failure can be reported for the right link */ + @Volatile private var currentUrl: String? = null + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.receiveAsFlow() + + val canvas: Canvas = object : Canvas() { + override fun addNotify() { + super.addNotify() + start(Native.getComponentID(this)) + } + + override fun removeNotify() { + shutdown() + super.removeNotify() + } + }.apply { background = Color.BLACK } + + private fun start(windowId: Long) = synchronized(this) { + if (started || closed) return + started = true + option("wid", windowId.toString()) + option("osc", "yes") + option("input-default-bindings", "yes") + option("input-vo-keyboard", "yes") + option("hwdec", "auto-safe") + // Stay on the last frame at the end instead of closing the file + option("keep-open", "yes") + option("idle", "yes") + option("force-window", "yes") + option("sub-auto", "no") + // Between files mpv would show its own logo and invite dropping files, neither belongs here + option("script-opts", "osc-idlescreen=no") + option("drag-and-drop", "no") + check("initialize", lib.mpv_initialize(ctx)) + // Keys inside the video go to mpv, so the app's own keys are rebound to messages it sends back + for ((key, message) in listOf("ESC" to "cs-back", "BS" to "cs-back", "f" to "cs-fullscreen", "MBTN_LEFT_DBL" to "cs-fullscreen")) { + command("keybind", key, "script-message $message") + } + eventThread = Thread(::eventLoop, "mpv-events").apply { isDaemon = true; start() } + pending.forEach { it() } + pending.clear() + } + + /** Runs now if mpv started, otherwise once it has */ + private fun whenStarted(action: () -> Unit) = synchronized(this) { + when { + closed -> Unit + started -> action() + else -> pending += action + } + } + + private fun shutdown() { + synchronized(this) { + if (closed) return + closed = true + if (!started) { + lib.mpv_terminate_destroy(ctx) + return + } + command("quit") + } + // The event thread destroys the handle; wait so mpv lets go of the window before it is destroyed + if (!stopped.await(5, TimeUnit.SECONDS)) println("ERROR MpvPlayer: mpv did not shut down in time") + } + + private fun eventLoop() { + try { + while (true) { + val event = lib.mpv_wait_event(ctx, -1.0) + when (event.getInt(0)) { + MPV_EVENT_SHUTDOWN -> return + MPV_EVENT_END_FILE -> { + val data = event.getPointer(16) + val reason = data.getInt(0) + val url = currentUrl ?: continue + when (reason) { + END_FILE_REASON_EOF -> _events.trySend(MpvEvent.Ended) + END_FILE_REASON_ERROR -> _events.trySend(MpvEvent.Failed(url, lib.mpv_error_string(data.getInt(4)))) + } + } + + MPV_EVENT_CLIENT_MESSAGE -> { + val data = event.getPointer(16) + if (data.getInt(0) < 1) continue + when (data.getPointer(8).getPointer(0).getString(0)) { + "cs-back" -> _events.trySend(MpvEvent.Back) + "cs-fullscreen" -> _events.trySend(MpvEvent.ToggleFullscreen) + } + } + } + } + } finally { + lib.mpv_terminate_destroy(ctx) + stopped.countDown() + } + } + + private fun option(name: String, value: String) = check(name, lib.mpv_set_option_string(ctx, name, value)) + + private fun command(vararg args: String): Int = lib.mpv_command(ctx, arrayOf(*args, null)).also { check(args.first(), it) } + + private fun check(what: String, code: Int) { + if (code < 0) println("ERROR MpvPlayer: $what failed: ${lib.mpv_error_string(code)}") + } + + private fun property(name: String): String? { + if (!started || closed) return null + val value = lib.mpv_get_property_string(ctx, name) ?: return null + return value.getString(0, "UTF-8").also { lib.mpv_free(value) } + } + + /** + * Plays a link with the headers the provider gave for it. The referer and user agent have their + * own mpv options, every other header is sent as is on every request, playlists and segments included. + */ + fun play(link: ExtractorLink, startSeconds: Double? = null) = whenStarted { + val headers = link.headers.toMutableMap() + fun take(name: String) = headers.keys.firstOrNull { it.equals(name, ignoreCase = true) }?.let { headers.remove(it) } + val userAgent = take("User-Agent") ?: USER_AGENT + val headerReferer = take("Referer") + val referer = link.referer.ifBlank { headerReferer.orEmpty() } + + currentUrl = link.url + command("set", "user-agent", userAgent) + command("set", "referrer", referer) + // Appended one by one, so a comma inside a header value is never taken as a separator + command("change-list", "http-header-fields", "clr", "") + headers.forEach { (key, value) -> command("change-list", "http-header-fields", "append", "$key: $value") } + command("set", "start", startSeconds?.let { "+$it" } ?: "none") + // keep-open pauses at the end of a file, which would carry over to the next one + command("set", "pause", "no") + command("loadfile", link.url, "replace") + link.audioTracks.forEach { command("audio-add", it.url, "auto") } + } + + /** Adds a subtitle file and shows it */ + fun addSubtitle(file: File, title: String) = whenStarted { + command("sub-add", file.absolutePath, "select", title) + } + + /** Shows a subtitle track, or hides subtitles when [id] is null */ + fun selectSubtitle(id: Int?) = whenStarted { command("set", "sid", id?.toString() ?: "no") } + + fun selectAudio(id: Int) = whenStarted { command("set", "aid", id.toString()) } + + fun togglePause() = whenStarted { command("cycle", "pause") } + + fun seek(seconds: Double) = whenStarted { command("seek", seconds.toString(), "relative") } + + fun changeVolume(delta: Int) = whenStarted { command("add", "volume", delta.toString()) } + + fun status(): MpvStatus { + if (!started || closed) return MpvStatus() + val count = property("track-list/count")?.toIntOrNull() ?: 0 + val tracks = (0 until count).mapNotNull { i -> + val prefix = "track-list/$i/" + MpvTrack( + id = property("${prefix}id")?.toIntOrNull() ?: return@mapNotNull null, + type = property("${prefix}type") ?: return@mapNotNull null, + title = property("${prefix}title"), + lang = property("${prefix}lang"), + selected = property("${prefix}selected") == "yes", + external = property("${prefix}external") == "yes", + ) + } + return MpvStatus( + position = property("time-pos")?.toDoubleOrNull() ?: 0.0, + duration = property("duration")?.toDoubleOrNull() ?: 0.0, + paused = property("pause") == "yes", + buffering = property("paused-for-cache") == "yes", + tracks = tracks, + ) + } + + private companion object { + const val MPV_EVENT_SHUTDOWN = 1 + const val MPV_EVENT_END_FILE = 7 + const val MPV_EVENT_CLIENT_MESSAGE = 16 + const val END_FILE_REASON_EOF = 0 + const val END_FILE_REASON_ERROR = 4 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt new file mode 100644 index 00000000000..e43a9d5e59e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -0,0 +1,279 @@ +package com.lagradost.cloudstream4.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream4.browse.Message +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.arrow_back +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource + +/** + * Plays a movie or episode. The links load in the background and the best one starts on its own. + * The bar on top switches source, subtitles and audio; mpv's own controls handle seeking. + * Space pauses, the arrows seek and change volume, F toggles full screen and Escape goes back. + */ +@Composable +fun PlayerScreen( + viewModel: LinksViewModel, + fullscreen: Boolean, + onBack: () -> Unit, + onToggleFullscreen: () -> Unit, +) { + val lib = Mpv.library + if (lib == null) { + Message("The video player is missing", Mpv.loadError) + return + } + val state by viewModel.state.collectAsState() + val player = remember { MpvPlayer(lib) } + var status by remember { mutableStateOf(MpvStatus()) } + val scope = rememberCoroutineScope() + var subtitleError by remember { mutableStateOf(null) } + var panel by remember { mutableStateOf(null) } + val currentBack by rememberUpdatedState(onBack) + val currentFullscreen by rememberUpdatedState(onToggleFullscreen) + + // A new link was picked, by hand or because the last one failed + val selected = state.selectedLink + LaunchedEffect(selected?.url) { + if (selected != null) player.play(selected) + } + LaunchedEffect(player) { + player.events.collect { event -> + when (event) { + MpvEvent.Back -> currentBack() + MpvEvent.ToggleFullscreen -> currentFullscreen() + is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(event.url, event.message)) + MpvEvent.Ended -> Unit + } + } + } + LaunchedEffect(player) { + while (true) { + status = player.status() + delay(1000) + } + } + + fun showSubtitle(subtitle: SubtitleFile) { + subtitleError = null + scope.launch { + runCatching { Subtitles.download(subtitle) } + .onSuccess { player.addSubtitle(it, subtitle.lang) } + .onFailure { subtitleError = "Subtitle could not be loaded: ${it.message}" } + } + } + + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + Column( + Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(focus) + .focusable() + .onPreviewKeyEvent { + if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (it.key) { + Key.Spacebar -> player.togglePause() + Key.DirectionLeft -> player.seek(-10.0) + Key.DirectionRight -> player.seek(10.0) + Key.DirectionUp -> player.changeVolume(5) + Key.DirectionDown -> player.changeVolume(-5) + Key.F -> onToggleFullscreen() + else -> return@onPreviewKeyEvent false + } + true + }, + ) { + if (!fullscreen) TopBar( + state = state, + status = status, + title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), + message = subtitleError, + panel = panel, + onPanel = { panel = it }, + onBack = onBack, + onRetry = { viewModel.onAction(LinksAction.Retry) }, + onToggleFullscreen = onToggleFullscreen, + ) + // The video is a native window, so nothing can be drawn over it: messages go in the bar above + Row(Modifier.fillMaxWidth().weight(1f)) { + SwingPanel(factory = { player.canvas }, modifier = Modifier.weight(1f).fillMaxHeight(), background = Color.Black) + val open = panel + if (open != null && !fullscreen) SidePanel( + panel = open, + state = state, + status = status, + onSelect = { viewModel.onAction(LinksAction.Select(it)) }, + onSubtitle = ::showSubtitle, + onSubtitleTrack = player::selectSubtitle, + onAudioTrack = player::selectAudio, + ) + } + } +} + +/** The pickers open beside the video: the video is a native window, so nothing can be drawn over it */ +private enum class Panel(val title: String) { Source("Source"), Subtitles("Subtitles"), Audio("Audio") } + +@Composable +private fun TopBar( + state: LinksState, + status: MpvStatus, + title: String, + message: String?, + panel: Panel?, + onPanel: (Panel?) -> Unit, + onBack: () -> Unit, + onRetry: () -> Unit, + onToggleFullscreen: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onBack) { Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back") } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + val line = message ?: when { + state.exhausted && state.links.isEmpty() -> "No playable links found" + + if (state.skipped > 0) " (${state.skipped} torrent or DRM links cannot play on desktop)" else "" + state.exhausted -> "None of the ${state.links.size} links could be played" + state.selected == null -> "Finding links… ${state.links.size} found" + status.buffering -> "Buffering…" + else -> state.selectedLink?.let(LinksViewModel::label) + } + line?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (state.loading) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + if (state.exhausted) TextButton(onClick = onRetry) { Text("Try again") } + + val audioTracks = status.tracks.count { it.type == "audio" } + Panel.entries.forEach { entry -> + val enabled = when (entry) { + Panel.Source -> state.links.isNotEmpty() + Panel.Subtitles -> state.subtitles.isNotEmpty() || status.tracks.any { it.type == "sub" } + Panel.Audio -> audioTracks > 1 + } + val label = if (entry == Panel.Source && state.links.isNotEmpty()) "Source · ${state.links.size}" else entry.title + // The audio button only shows when there is a choice to make + if (entry != Panel.Audio || enabled) { + TextButton(onClick = { onPanel(if (panel == entry) null else entry) }, enabled = enabled) { + Text((if (panel == entry) "▸ " else "") + label) + } + } + } + TextButton(onClick = onToggleFullscreen) { Text("Full screen") } + } +} + +@Composable +private fun SidePanel( + panel: Panel, + state: LinksState, + status: MpvStatus, + onSelect: (String) -> Unit, + onSubtitle: (SubtitleFile) -> Unit, + onSubtitleTrack: (Int?) -> Unit, + onAudioTrack: (Int) -> Unit, +) { + LazyColumn(Modifier.width(320.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface)) { + item { Text(panel.title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(16.dp)) } + when (panel) { + Panel.Source -> items(state.links) { link -> + Choice( + text = LinksViewModel.label(link), + detail = state.failed[link.url]?.let { "Failed: $it" } ?: link.source.takeIf { it != link.name }, + selected = link.url == state.selected, + error = link.url in state.failed, + ) { onSelect(link.url) } + } + + Panel.Subtitles -> { + val tracks = status.tracks.filter { it.type == "sub" } + item { Choice("Off", selected = tracks.none { it.selected }) { onSubtitleTrack(null) } } + items(tracks) { track -> + Choice(track.label, detail = if (track.external) "From the extension" else "In the video", selected = track.selected) { + onSubtitleTrack(track.id) + } + } + // Extension subtitles not added yet, each is downloaded when picked + val notAdded = state.subtitles.filter { sub -> tracks.none { it.external && it.title == sub.lang } } + items(notAdded) { sub -> Choice(sub.lang, detail = "From the extension", selected = false) { onSubtitle(sub) } } + } + + Panel.Audio -> items(status.tracks.filter { it.type == "audio" }) { track -> + Choice(track.label, selected = track.selected) { onAudioTrack(track.id) } + } + } + } +} + +@Composable +private fun Choice(text: String, detail: String? = null, selected: Boolean, error: Boolean = false, onClick: () -> Unit) { + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + detail?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt new file mode 100644 index 00000000000..f22bdb03655 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt @@ -0,0 +1,46 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream4.AppDirs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.IOException +import java.security.MessageDigest + +/** + * Downloads provider subtitles to the cache folder, so mpv opens a local file. Each subtitle can have + * its own headers, which mpv could only send by changing the headers of the video too. + */ +object Subtitles { + private val http = OkHttpClient() + private val dir: File get() = AppDirs.cache.resolve("subtitles").toFile() + + suspend fun download(subtitle: SubtitleFile): File = withContext(Dispatchers.IO) { + val name = MessageDigest.getInstance("SHA-1").digest(subtitle.url.toByteArray()).joinToString("") { "%02x".format(it) } + // mpv reads the format from the content, the extension only helps when it is known + val extension = subtitle.url.substringBefore('?').substringAfterLast('.', "").lowercase() + .takeIf { it in setOf("srt", "vtt", "ass", "ssa", "sub", "ttml", "dfxp") } ?: "sub" + val file = dir.resolve("$name.$extension") + if (file.isFile && file.length() > 0) return@withContext file + + dir.mkdirs() + val request = Request.Builder().url(subtitle.url).apply { + header("User-Agent", USER_AGENT) + subtitle.headers?.forEach { (key, value) -> header(key, value) } + }.build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code} for ${subtitle.url}") + val tmp = File(dir, "$name.tmp") + tmp.outputStream().use { response.body.byteStream().copyTo(it) } + if (!tmp.renameTo(file)) { + file.delete() + tmp.renameTo(file) + } + } + file + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02c26cf5594..343e7d503b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ fragmentKtx = "1.8.9" instancioCore = "5.6.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20260522" +jna = "5.19.1" jsoup = "1.22.1" junit = "4.13.2" junitKtx = "1.3.0" @@ -97,6 +98,7 @@ fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragm instancio-core = { group = "org.instancio", name = "instancio-core", version.ref = "instancioCore" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } json = { module = "org.json:json", version.ref = "json" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt new file mode 100644 index 00000000000..d3711bb05aa --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream4.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.providers.loadSafely +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** What the player needs to find and play the links of one movie or episode */ +data class PlayRequest( + val apiName: String, + val title: String, + /** For example "S1 E3 · Pilot", null for a movie */ + val episodeLabel: String?, + val data: String, +) + +/** One season of episodes, or all episodes when the provider has no seasons */ +data class SeasonGroup( + val season: Int?, + val label: String, + val episodes: List, +) + +sealed interface DetailStatus { + data object Loading : DetailStatus + data class Failed(val message: String) : DetailStatus + data object Done : DetailStatus +} + +data class DetailState( + val status: DetailStatus = DetailStatus.Loading, + val response: LoadResponse? = null, + /** Set for movies and live streams, played directly */ + val movieData: String? = null, + /** Dubbed, subbed... when the provider splits episodes that way, otherwise empty */ + val dubs: List = emptyList(), + val selectedDub: DubStatus? = null, + val seasons: List = emptyList(), + val selectedSeason: Int = 0, + /** Why this item cannot be played on desktop, for example a torrent */ + val unsupported: String? = null, +) + +sealed interface DetailAction { + data class SelectSeason(val index: Int) : DetailAction + data class SelectDub(val dub: DubStatus) : DetailAction + data object Retry : DetailAction +} + +/** The details page of one item: its info, and either a play button or its seasons and episodes */ +class DetailViewModel( + private val api: MainAPI, + private val url: String, +) : ViewModel(), StateContainer by DefaultStateContainer(DetailState()), + ActionHandler { + + private var loadJob: Job? = null + /** Episodes per dub, or under null when the provider does not split them */ + private var episodesByDub: Map> = emptyMap() + + init { + load() + } + + override fun onAction(action: DetailAction) { + when (action) { + is DetailAction.SelectSeason -> updateState { + copy(selectedSeason = action.index.coerceIn(0, (seasons.size - 1).coerceAtLeast(0))) + } + + is DetailAction.SelectDub -> { + val response = state.value.response ?: return + if (action.dub !in episodesByDub) return + updateState { + copy(selectedDub = action.dub, seasons = groupSeasons(response, episodesByDub[action.dub].orEmpty()), selectedSeason = 0) + } + } + + DetailAction.Retry -> load() + } + } + + private fun load() { + loadJob?.cancel() + updateState { DetailState() } + loadJob = viewModelScope.launch { + when (val result = api.loadSafely(url)) { + is Resource.Success -> show(result.value) + is Resource.Failure -> updateState { copy(status = DetailStatus.Failed(result.errorString)) } + is Resource.Loading -> Unit + } + } + } + + private fun show(response: LoadResponse) { + episodesByDub = when (response) { + is TvSeriesLoadResponse -> mapOf(null to response.episodes) + is AnimeLoadResponse -> response.episodes.filterValues { it.isNotEmpty() } + else -> emptyMap() + } + val dubs = episodesByDub.keys.filterNotNull() + val dub = dubs.firstOrNull() + updateState { + DetailState( + status = DetailStatus.Done, + response = response, + movieData = when (response) { + is MovieLoadResponse -> response.dataUrl + is LiveStreamLoadResponse -> response.dataUrl + else -> null + }, + dubs = dubs, + selectedDub = dub, + seasons = groupSeasons(response, episodesByDub[dub].orEmpty()), + unsupported = if (response is TorrentLoadResponse) "Torrents cannot be played on desktop yet." else null, + ) + } + } + + /** What to play for an episode, or for the movie when [episode] is null */ + fun playRequest(episode: Episode? = null): PlayRequest? { + val response = state.value.response ?: return null + val data = episode?.data ?: state.value.movieData ?: return null + return PlayRequest(api.name, response.name, episode?.let(::episodeLabel), data) + } + + companion object { + /** Groups episodes by season in season order, keeping the provider's order inside a season */ + fun groupSeasons(response: LoadResponse, episodes: List): List { + if (episodes.isEmpty()) return emptyList() + val names = (response as? EpisodeResponse)?.seasonNames.orEmpty() + val bySeason = episodes.groupBy { it.season } + if (bySeason.keys == setOf(null)) return listOf(SeasonGroup(null, "Episodes", episodes)) + return bySeason.entries + .sortedBy { it.key ?: Int.MAX_VALUE } + .map { (season, list) -> + val data = names.firstOrNull { it.season == season } + val label = if (season == null) "Other" else data?.name ?: "Season ${data?.displaySeason ?: season}" + SeasonGroup(season, label, list) + } + } + + /** For example "S1 E3 · Pilot" */ + fun episodeLabel(episode: Episode): String { + val number = listOfNotNull(episode.season?.let { "S$it" }, episode.episode?.let { "E$it" }).joinToString(" ") + return listOf(number, episode.name.orEmpty().trim()).filter { it.isNotEmpty() }.joinToString(" · ") + .ifEmpty { "Episode" } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt new file mode 100644 index 00000000000..6692fb10720 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt @@ -0,0 +1,134 @@ +package com.lagradost.cloudstream4.player + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.providers.loadLinksSafely +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +data class LinksState( + /** Playable links, best quality first */ + val links: List = emptyList(), + val subtitles: List = emptyList(), + val loading: Boolean = true, + /** The url of the link being played */ + val selected: String? = null, + /** Links that failed to play, by url, with the reason */ + val failed: Map = emptyMap(), + /** Links found that the desktop player cannot use, such as torrents or DRM */ + val skipped: Int = 0, +) { + val selectedLink: ExtractorLink? get() = links.firstOrNull { it.url == selected } + + /** Loading finished and nothing is left to try */ + val exhausted: Boolean get() = !loading && links.none { it.url !in failed } +} + +sealed interface LinksAction { + data class Select(val url: String) : LinksAction + /** The player could not play this link, so the next one is tried */ + data class Failed(val url: String, val reason: String) : LinksAction + data object Retry : LinksAction +} + +/** + * Finds the links of one movie or episode and picks which to play: the best one once all links are + * in, or the best so far when links keep coming after [autoPickAfter]. A link that fails to play + * moves on to the next best one. + */ +class LinksViewModel( + private val api: MainAPI, + val request: PlayRequest, + private val autoPickAfter: Duration = 6.seconds, +) : ViewModel(), StateContainer by DefaultStateContainer(LinksState()), ActionHandler { + + private var loadJob: Job? = null + /** Set once a link was picked automatically: from then on a new link plays if nothing else is */ + @kotlin.concurrent.Volatile + private var autoPicking = false + + init { + load() + } + + override fun onAction(action: LinksAction) { + when (action) { + is LinksAction.Select -> updateState { copy(selected = action.url) } + is LinksAction.Failed -> updateState { + val failed = failed + (action.url to action.reason) + // Only move on if the failed link is still the one playing + copy(failed = failed, selected = if (selected == action.url) nextLink(links, failed) else selected) + } + + LinksAction.Retry -> load() + } + } + + private fun load() { + loadJob?.cancel() + autoPicking = false + updateState { LinksState() } + loadJob = viewModelScope.launch { + val picker = launch { + // Some extractors take minutes, so do not wait for all of them once a link is in + state.first { it.links.isNotEmpty() } + delay(autoPickAfter) + pickIfNone() + } + api.loadLinksSafely( + request.data, + onSubtitle = { sub -> updateState { if (subtitles.any { it.url == sub.url }) this else copy(subtitles = subtitles + sub) } }, + onLink = ::addLink, + ) + picker.cancel() + // One update, so nobody sees loading finished with nothing picked yet + autoPicking = true + updateState { copy(loading = false, selected = selected ?: nextLink(links, failed)) } + } + } + + private fun addLink(link: ExtractorLink) { + val playable = link !is DrmExtractorLink && link.type != ExtractorLinkType.TORRENT && link.type != ExtractorLinkType.MAGNET + updateState { + when { + !playable -> copy(skipped = skipped + 1) + links.any { it.url == link.url } -> this + else -> { + val links = (links + link).sortedByDescending { rank(it.quality) } + copy(links = links, selected = selected ?: if (autoPicking) nextLink(links, failed) else null) + } + } + } + } + + private fun pickIfNone() { + autoPicking = true + updateState { if (selected != null) this else copy(selected = nextLink(links, failed)) } + } + + companion object { + private fun nextLink(links: List, failed: Map) = + links.firstOrNull { it.url !in failed }?.url + + /** Higher is better. An unknown quality ranks with 480p, as on Android */ + fun rank(quality: Int): Int = if (quality == Qualities.Unknown.value) Qualities.P480.value else quality + + /** For example "Vidplay · 1080p" */ + fun label(link: ExtractorLink): String = + listOf(link.name, Qualities.getStringByInt(link.quality)).filter { it.isNotBlank() }.distinct().joinToString(" · ") + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt index 104003544c7..2893ac4400b 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/providers/Providers.kt @@ -4,17 +4,26 @@ import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.newSearchResponseList +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.workerDispatcher +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlin.coroutines.cancellation.CancellationException /** * The providers the user wants to see, the same rules as filterProviderByPreferredMedia in the @@ -79,3 +88,42 @@ suspend fun MainAPI.mainPageSafely(page: Int): Resource> } } } + +/** Providers use these as "nothing here", so they are never sent to load or loadLinks */ +private fun isInvalidData(data: String) = data.isEmpty() || data == "[]" || data == "about:blank" + +/** The details page of a search result or home page item */ +suspend fun MainAPI.loadSafely(url: String): Resource = safeApiCall { + withTimeout(providerTimeout(loadTimeoutMs)) { + if (isInvalidData(url)) throw ErrorLoadingException() + val response = load(fixUrl(url)) ?: throw ErrorLoadingException() + response.apply { tags = tags?.filter { it.isNotBlank() } } + } +} + +/** + * Finds the video links for a movie or episode. Links and subtitles are passed on as soon as they + * are found, from worker threads. Returns false if the provider failed or ran out of time. + */ +suspend fun MainAPI.loadLinksSafely( + data: String, + onSubtitle: (SubtitleFile) -> Unit, + onLink: (ExtractorLink) -> Unit, +): Boolean { + if (isInvalidData(data)) return false + return try { + withContext(workerDispatcher) { + withTimeout(providerTimeout(loadLinksTimeoutMs)) { + loadLinks(data, false, onSubtitle, onLink) + } + } + } catch (t: TimeoutCancellationException) { + logError(t) + false + } catch (t: Throwable) { + // Cancellation means the screen closed, which must reach the caller + if (t is CancellationException) throw t + logError(t) + false + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt new file mode 100644 index 00000000000..9292ac277b3 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt @@ -0,0 +1,218 @@ +package com.lagradost.cloudstream4 + +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.addEpisodes +import com.lagradost.cloudstream3.addSeasonNames +import com.lagradost.cloudstream3.newAnimeLoadResponse +import com.lagradost.cloudstream3.newEpisode +import com.lagradost.cloudstream3.newMovieLoadResponse +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.newTorrentLoadResponse +import com.lagradost.cloudstream3.newTvSeriesLoadResponse +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream4.detail.DetailAction +import com.lagradost.cloudstream4.detail.DetailStatus +import com.lagradost.cloudstream4.detail.DetailViewModel +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.player.LinksAction +import com.lagradost.cloudstream4.player.LinksViewModel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +/** Serves a movie, a series, an anime and a torrent, and links for each, from memory */ +private class FakeCatalog : MainAPI() { + override var name = "Catalog" + override var mainUrl = "https://example.invalid" + + /** loadLinks waits for this before sending the links listed after [slowAfter] */ + var gate: CompletableDeferred? = null + var slowAfter = Int.MAX_VALUE + var links: List> = emptyList() + var torrents = 0 + + private fun ep(data: String, season: Int?, number: Int, title: String? = null) = + newEpisode(data, initializer = { this.season = season; this.episode = number; this.name = title }, fix = false) + + override suspend fun load(url: String): LoadResponse = when (url.substringAfterLast('/')) { + "movie" -> newMovieLoadResponse("A movie", url, TvType.Movie, "movie-data") { plot = "Plot"; tags = listOf("Drama", " ") } + "series" -> newTvSeriesLoadResponse( + "A series", url, TvType.TvSeries, + listOf(ep("s2e1", 2, 1), ep("s1e1", 1, 1, "Pilot"), ep("s1e2", 1, 2), ep("extra", null, 1)), + ) { addSeasonNames(listOf(SeasonData(1, "The beginning"), SeasonData(2, displaySeason = 20))) } + + "anime" -> newAnimeLoadResponse("An anime", url, TvType.Anime) { + addEpisodes(DubStatus.Subbed, listOf(ep("sub1", null, 1), ep("sub2", null, 2))) + addEpisodes(DubStatus.Dubbed, listOf(ep("dub1", null, 1))) + } + + "torrent" -> newTorrentLoadResponse("A torrent", url, magnet = "magnet:?xt=x") + else -> throw IllegalStateException("not found") + } + + override suspend fun loadLinks( + data: String, + isCasting: Boolean, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit, + ): Boolean { + subtitleCallback(newSubtitleFile("English", "https://example.invalid/en.vtt")) + subtitleCallback(newSubtitleFile("English", "https://example.invalid/en.vtt")) + repeat(torrents) { callback(newExtractorLink("T", "Torrent", "magnet:?xt=$it", ExtractorLinkType.MAGNET)) } + links.forEachIndexed { index, (name, quality) -> + if (index == slowAfter) gate?.await() + callback(newExtractorLink(name, name, "https://example.invalid/$name.m3u8", ExtractorLinkType.M3U8) { this.quality = quality }) + } + return links.isNotEmpty() + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaybackViewModelTest { + private val api = FakeCatalog() + + // Not a test dispatcher: the links view model waits in real time before picking a link + @BeforeTest + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun await(flow: StateFlow, predicate: (T) -> Boolean): T = + runBlocking { withTimeout(5_000) { flow.first(predicate) } } + + @Test + fun movieGetsAPlayRequestAndBlankTagsAreDropped() { + val vm = DetailViewModel(api, "https://example.invalid/movie") + val state = await(vm.state) { it.status == DetailStatus.Done } + assertEquals("movie-data", state.movieData) + assertEquals(listOf("Drama"), state.response!!.tags) + assertTrue(state.seasons.isEmpty()) + assertEquals(PlayRequest("Catalog", "A movie", null, "movie-data"), vm.playRequest()) + } + + @Test + fun seriesEpisodesAreGroupedBySeasonWithTheirNames() { + val vm = DetailViewModel(api, "https://example.invalid/series") + val state = await(vm.state) { it.status == DetailStatus.Done } + assertEquals(listOf("The beginning", "Season 20", "Other"), state.seasons.map { it.label }) + assertEquals(listOf("s1e1", "s1e2"), state.seasons[0].episodes.map { it.data }) + + val pilot = state.seasons[0].episodes[0] + assertEquals(PlayRequest("Catalog", "A series", "S1 E1 · Pilot", "s1e1"), vm.playRequest(pilot)) + + vm.onAction(DetailAction.SelectSeason(1)) + assertEquals(1, vm.state.value.selectedSeason) + vm.onAction(DetailAction.SelectSeason(99)) + assertEquals(2, vm.state.value.selectedSeason) + } + + @Test + fun animeEpisodesCanBeSwitchedBetweenSubbedAndDubbed() { + val vm = DetailViewModel(api, "https://example.invalid/anime") + val state = await(vm.state) { it.status == DetailStatus.Done } + assertEquals(listOf(DubStatus.Subbed, DubStatus.Dubbed), state.dubs) + assertEquals(listOf("sub1", "sub2"), state.seasons.single().episodes.map { it.data }) + assertEquals("Episodes", state.seasons.single().label) + + vm.onAction(DetailAction.SelectDub(DubStatus.Dubbed)) + assertEquals(listOf("dub1"), vm.state.value.seasons.single().episodes.map { it.data }) + assertEquals("E1", DetailViewModel.episodeLabel(vm.state.value.seasons.single().episodes.single())) + } + + @Test + fun torrentsAreMarkedUnsupportedAndFailuresCanBeRetried() { + val torrent = await(DetailViewModel(api, "https://example.invalid/torrent").state) { it.status == DetailStatus.Done } + assertTrue(torrent.unsupported != null) + + val missing = DetailViewModel(api, "https://example.invalid/missing") + assertIs(await(missing.state) { it.status is DetailStatus.Failed }.status) + missing.onAction(DetailAction.Retry) + assertIs(await(missing.state) { it.status is DetailStatus.Failed }.status) + } + + private fun links(vararg links: Pair, autoPickMs: Long = 50): LinksViewModel { + api.links = links.toList() + return LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data"), autoPickMs.milliseconds) + } + + @Test + fun theBestLinkIsPickedAndTorrentsAreSkipped() { + api.torrents = 2 + val vm = links("low" to Qualities.P360.value, "unknown" to Qualities.Unknown.value, "hd" to Qualities.P1080.value, "sd" to Qualities.P720.value) + val state = await(vm.state) { !it.loading } + + assertEquals(listOf("hd", "sd", "unknown", "low"), state.links.map { it.name }) + assertEquals("hd", state.selectedLink?.name) + assertEquals(2, state.skipped) + assertEquals(1, state.subtitles.size) + assertEquals("hd · 1080p", LinksViewModel.label(state.links[0])) + } + + @Test + fun aFailedLinkMovesOnToTheNextUntilNoneAreLeft() { + val vm = links("a" to 1080, "b" to 720) + await(vm.state) { !it.loading } + + vm.onAction(LinksAction.Failed("https://example.invalid/a.m3u8", "403")) + assertEquals("b", vm.state.value.selectedLink?.name) + // A late failure report for a link no longer playing changes nothing + vm.onAction(LinksAction.Failed("https://example.invalid/a.m3u8", "403")) + assertEquals("b", vm.state.value.selectedLink?.name) + + vm.onAction(LinksAction.Failed("https://example.invalid/b.m3u8", "timeout")) + assertNull(vm.state.value.selected) + assertTrue(vm.state.value.exhausted) + + vm.onAction(LinksAction.Select("https://example.invalid/a.m3u8")) + assertEquals("a", vm.state.value.selectedLink?.name) + } + + @Test + fun aSlowExtractorDoesNotHoldBackPlayback() { + api.gate = CompletableDeferred() + api.slowAfter = 1 + val vm = links("fast" to 480, "slow" to 1080) + + // The fast link plays while the slow extractor is still working + val early = await(vm.state) { it.selected != null } + assertTrue(early.loading) + assertEquals("fast", early.selectedLink?.name) + + api.gate!!.complete(Unit) + val done = await(vm.state) { !it.loading } + assertEquals(listOf("slow", "fast"), done.links.map { it.name }) + // The better link arriving later does not interrupt what is playing + assertEquals("fast", done.selectedLink?.name) + } + + @Test + fun noLinksMeansExhausted() { + val vm = links() + val state = await(vm.state) { !it.loading } + assertNull(state.selected) + assertTrue(state.exhausted) + } +} From 4b436252e1a2436ec9fcd24d6315f93bce7c2a1c Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 11:54:12 +0530 Subject: [PATCH 08/64] Desktop: repository short codes and MegaRepo The Add repository box takes what Android takes: a URL, a cloudstreamrepo:// or https://cs.repo/ link, or a short code such as "megarepo", looked up at cutt.ly (or py.md for "!code") like RepositoryManager.parseRepoUrl. MegaRepo holds one plugin, MegaProvider, an Android helper that adds every repository in recloudstream/cs-repos through app internals desktop does not have. So when a short code or URL leads to MegaRepo, the app adds those community repositories itself. Opening MegaRepo explains this and offers the same, and the Extensions screen has an "Add all community repositories" button. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/ExtensionsSettings.kt | 116 +++++++++++++++--- .../cloudstream4/plugins/RepositoryClient.kt | 55 +++++++++ .../cloudstream4/plugins/PluginTest.kt | 63 ++++++++++ 3 files changed, 220 insertions(+), 14 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt index 5af01cb4ae5..77ee0cf6487 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -37,6 +37,7 @@ import com.mihon.presentation.settings.Preference import com.mihon.presentation.settings.SearchableSettings import com.mihon.presentation.settings.collectAsState import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -46,6 +47,30 @@ private val repositories = desktopPreferences.getStringSet("desktop_repositories private val client = RepositoryClient() +/** Adds every repository in the community list, which is what MegaRepo does on Android. Returns how many were new */ +private suspend fun addCommunityRepositories(): Int { + val community = client.fetchCommunityRepositories() + val new = community.filterNot { it in repositories.get() } + repositories.set(repositories.get() + new) + return new.size +} + +/** + * Adds what the user typed: a repository URL, a cloudstreamrepo:// link or a short code such as + * "megarepo". MegaRepo itself is not added: its only plugin is an Android helper that adds the + * community repositories, so they are added directly instead. Returns a message for the user. + */ +private suspend fun addRepository(input: String): String { + val url = client.resolveRepositoryUrl(input) ?: return "No repository found for \"${input.trim()}\"" + val plugins = client.fetchPlugins(client.fetchRepository(url)) + if (plugins.isNotEmpty() && plugins.all { it.internalName == RepositoryClient.MEGA_REPO_PLUGIN }) { + val added = addCommunityRepositories() + return "MegaRepo: added $added community repositories" + if (added == 0) ", you already had them all" else "" + } + repositories += url + return "Added ${url.removePrefix("https://")}" +} + /** Repositories and installed extensions. Only plugins built with `isCrossPlatform = true` can run on desktop */ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { @Composable @@ -72,6 +97,22 @@ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : Searcha val addRepository = Preference.PreferenceItem.CustomPreference(title = "Add repository") { var text by remember { mutableStateOf("") } + var adding by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + fun run(action: suspend () -> String) { + adding = true + message = null + scope.launch { + message = try { + action() + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + "Could not add it: ${t.message ?: t}" + } + adding = false + } + } Column { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), @@ -83,21 +124,35 @@ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : Searcha onValueChange = { text = it }, modifier = Modifier.weight(1f), singleLine = true, - label = { Text("Repository URL (repo.json)") }, + label = { Text("Repository URL or short code, for example megarepo") }, ) - Button( - enabled = text.trim().startsWith("http"), - onClick = { - repositories += text.trim() - text = "" - }, - ) { Text("Add") } + BusyOr(adding) { + Button( + enabled = text.isNotBlank(), + onClick = { + val input = text + run { addRepository(input).also { text = "" } } + }, + ) { Text("Add") } + } } - if (RepositoryClient.OFFICIAL_REPOSITORY !in repos) { - TextButton( - modifier = Modifier.padding(horizontal = 8.dp), - onClick = { repositories += RepositoryClient.OFFICIAL_REPOSITORY }, - ) { Text("Add the official repository") } + message?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Row(Modifier.padding(horizontal = 8.dp)) { + if (RepositoryClient.OFFICIAL_REPOSITORY !in repos) { + TextButton(onClick = { repositories += RepositoryClient.OFFICIAL_REPOSITORY }) { + Text("Add the official repository") + } + } + TextButton(enabled = !adding, onClick = { + run { "Added ${addCommunityRepositories()} community repositories" } + }) { Text("Add all community repositories") } } } } @@ -173,7 +228,9 @@ class RepositoryScreen(private val url: String) : SearchableSettings { is State.Failed -> listOf(Preference.PreferenceItem.InfoPreference("Could not load $url: ${current.message}")) - is State.Loaded -> { + is State.Loaded -> if (current.plugins.any { it.internalName == RepositoryClient.MEGA_REPO_PLUGIN }) { + listOf(megaRepoGroup(scope)) + } else { val (desktop, androidOnly) = current.plugins.sortedBy { it.name.lowercase() }.partition { it.supportsDesktop } // Product Sans draws "(5)" as a circled digit, so counts use a separator instead of parentheses listOfNotNull( @@ -246,6 +303,37 @@ class RepositoryScreen(private val url: String) : SearchableSettings { } } +/** MegaRepo holds no extensions, only an Android helper that adds the community repositories */ +@Composable +private fun megaRepoGroup(scope: CoroutineScope): Preference.PreferenceGroup { + var result by remember { mutableStateOf(null) } + var adding by remember { mutableStateOf(false) } + return Preference.PreferenceGroup( + title = "MegaRepo", + preferenceItems = listOf( + Preference.PreferenceItem.InfoPreference( + "MegaRepo has no extensions of its own. Its only plugin, MegaProvider, adds all the community " + + "repositories on Android. On desktop the app does that for you." + ), + Preference.PreferenceItem.CustomPreference("Add all community repositories") { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + BusyOr(adding) { + Button(onClick = { + adding = true + scope.launch { + result = runCatching { "Added ${addCommunityRepositories()} community repositories" } + .getOrElse { "Could not add them: ${it.message ?: it}" } + adding = false + } + }) { Text("Add all community repositories") } + } + result?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + }, + ), + ) +} + @Composable private fun BusyOr(busy: Boolean, content: @Composable () -> Unit) { if (busy) { diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt index 42886525e7f..53fc13925cf 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt @@ -59,7 +59,49 @@ class RepositoryClient( .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(), + /** Where short codes such as "megarepo" are looked up, and "!code" ones. Changed by tests only */ + private val shortLinkHost: String = "https://cutt.ly/", + private val bangShortLinkHost: String = "https://py.md/", ) { + /** + * Turns what the user typed into a repository URL, with the same rules as the Android app's + * RepositoryManager.parseRepoUrl: cloudstreamrepo:// and https://cs.repo/ links lose their prefix, + * a URL is kept, and a short code is looked up at cutt.ly (or py.md for "!code"), which redirects + * to the repository. Returns null when nothing is found. + */ + suspend fun resolveRepositoryUrl(input: String): String? { + val text = input.trim() + // Before the URL check, which https://cs.repo/ links would otherwise pass unchanged + if (text.contains(DEEP_LINK_REGEX)) { + val rest = text.replace(DEEP_LINK_REGEX, "") + return if (rest.contains(URL_REGEX)) rest else "https://$rest" + } + if (text.contains(URL_REGEX)) return text + if (!text.matches(SHORT_CODE_REGEX)) return null + val (host, code) = if (text.startsWith("!")) bangShortLinkHost to text.removePrefix("!") else shortLinkHost to text + val location = withContext(Dispatchers.IO) { + val noRedirects = http.newBuilder().followRedirects(false).followSslRedirects(false).build() + noRedirects.newCall(Request.Builder().url(host + code).build()).execute().use { it.header("Location") } + } ?: return null + // The short link sites send unknown codes to their 404 page or their home page + val base = host.removeSuffix("/") + if (location.startsWith("$base/404") || location.removeSuffix("/") == base) return null + return location + } + + /** + * The repositories in CloudStream's community list, the same list the MegaRepo plugin adds on + * Android. Entries are either a URL or an object with a "url". + */ + suspend fun fetchCommunityRepositories(listUrl: String = COMMUNITY_REPOSITORIES): List = + Json.parseToJsonElement(getText(listUrl)).jsonArray.mapNotNull { entry -> + when (entry) { + is JsonPrimitive -> entry.takeIf { it.isString }?.content + is JsonObject -> entry.string("url") + else -> null + } + }.distinct() + suspend fun fetchRepository(url: String): Repository { val json = Json.parseToJsonElement(getText(url)) // Some people share the plugin list directly instead of repo.json @@ -111,6 +153,19 @@ class RepositoryClient( companion object { const val OFFICIAL_REPOSITORY = "https://raw.githubusercontent.com/recloudstream/extensions/master/repo.json" + /** The community repository list, see https://github.com/recloudstream/cs-repos */ + const val COMMUNITY_REPOSITORIES = "https://raw.githubusercontent.com/recloudstream/cs-repos/master/repos-db.json" + + /** + * The internal name of MegaRepo's only plugin. On Android it adds every community repository and + * nothing else, through app internals desktop does not have, so desktop does that itself instead. + */ + const val MEGA_REPO_PLUGIN = "MegaProvider" + + private val URL_REGEX = "^https?://".toRegex() + private val DEEP_LINK_REGEX = """^(cloudstreamrepo://|https://cs\.repo/\??)""".toRegex() + private val SHORT_CODE_REGEX = "^[a-zA-Z0-9!_-]+$".toRegex() + internal fun parsePluginList(text: String): List = Json.parseToJsonElement(text).jsonArray.mapNotNull { element -> val obj = element as? JsonObject ?: return@mapNotNull null diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt index 0e0e3413780..65200f298b1 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt @@ -164,6 +164,69 @@ class PluginTest { assertTrue(manager.installed.value.isEmpty()) } + /** A short link site: known codes redirect to a repository, unknown ones to its 404 page */ + private fun serveShortLinks(): String { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).also { this.server = it } + val base = "http://127.0.0.1:${server.address.port}" + server.createContext("/") { exchange -> + val location = when (exchange.requestURI.path) { + "/megarepo" -> "https://raw.githubusercontent.com/self-similarity/MegaRepo/builds/repo.json" + "/home" -> "$base/" + else -> "$base/404" + } + exchange.responseHeaders.add("Location", location) + exchange.sendResponseHeaders(301, -1) + exchange.close() + } + server.start() + return "$base/" + } + + @Test + fun repositoryShortCodesAndLinksResolveLikeAndroid() = runBlocking { + val shortLinks = serveShortLinks() + val client = RepositoryClient(shortLinkHost = shortLinks, bangShortLinkHost = shortLinks) + + assertEquals("https://raw.githubusercontent.com/self-similarity/MegaRepo/builds/repo.json", client.resolveRepositoryUrl(" megarepo ")) + assertEquals("https://raw.githubusercontent.com/self-similarity/MegaRepo/builds/repo.json", client.resolveRepositoryUrl("!megarepo")) + assertEquals(null, client.resolveRepositoryUrl("unknown-code")) + assertEquals(null, client.resolveRepositoryUrl("home")) + assertEquals(null, client.resolveRepositoryUrl("not a code")) + + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("https://example.com/repo.json")) + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("cloudstreamrepo://example.com/repo.json")) + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("https://cs.repo/?https://example.com/repo.json")) + assertEquals("https://example.com/repo.json", client.resolveRepositoryUrl("https://cs.repo/example.com/repo.json")) + } + + @Test + fun communityRepositoryListTakesBothEntryForms() = runBlocking { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).also { this@PluginTest.server = it } + val body = """["https://a/repo.json", {"url":"https://b/repo.json","verified":true}, {"name":"no url"}, "https://a/repo.json"]""".toByteArray() + server.createContext("/repos-db.json") { it.sendResponseHeaders(200, body.size.toLong()); it.responseBody.write(body); it.close() } + server.start() + + val repos = RepositoryClient().fetchCommunityRepositories("http://127.0.0.1:${server.address.port}/repos-db.json") + assertEquals(listOf("https://a/repo.json", "https://b/repo.json"), repos) + } + + /** + * Resolves the real megarepo short code and reads the real community list. + * Needs the internet, so it only runs with CLOUDSTREAM_ONLINE_TESTS=1. + */ + @Test + fun megarepoShortCodeAndCommunityListOnline() = runBlocking { + if (System.getenv("CLOUDSTREAM_ONLINE_TESTS") != "1") return@runBlocking println("Skipped, set CLOUDSTREAM_ONLINE_TESTS=1") + val client = RepositoryClient() + val url = client.resolveRepositoryUrl("megarepo") + println("megarepo -> $url") + val plugins = client.fetchPlugins(client.fetchRepository(url!!)) + assertEquals(listOf(RepositoryClient.MEGA_REPO_PLUGIN), plugins.map { it.internalName }) + val community = client.fetchCommunityRepositories() + println("${community.size} community repositories") + assertTrue(community.size > 10) + } + /** * Installs a real plugin from the official repository and searches with it. * Needs the internet, so it only runs with CLOUDSTREAM_ONLINE_TESTS=1. From 938355fac5c040a4d6adf49d0394b14b5e4d9a6c Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 13:44:34 +0530 Subject: [PATCH 09/64] Implement Android extension conversion and stubbing support - Added DexConverter for converting Android extensions (.cs3) to JAR files for desktop compatibility. - Introduced MissingMembers to handle missing methods in desktop versions of Android classes. - Created StubGenerator and StubModel to generate stubs for Android classes with missing members. - Established StubHierarchy to define the superclass relationships for stubbed Android classes. - Developed AndroidExtensionsTest to validate the functionality of Android extensions on desktop, including conversion and search capabilities. --- androidCompat/build.gradle.kts | 45 +++ .../jvmMain/kotlin/android/app/Activity.kt | 41 +++ .../jvmMain/kotlin/android/content/Context.kt | 85 +++++ .../jvmMain/kotlin/android/content/Intent.kt | 103 ++++++ .../android/content/SharedPreferences.kt | 33 ++ .../android/content/pm/PackageManager.kt | 41 +++ .../kotlin/android/content/res/Resources.kt | 59 +++ .../src/jvmMain/kotlin/android/net/Uri.kt | 187 ++++++++++ .../src/jvmMain/kotlin/android/os/Build.kt | 66 ++++ .../src/jvmMain/kotlin/android/os/Bundle.kt | 71 ++++ .../src/jvmMain/kotlin/android/os/Handler.kt | 107 ++++++ .../jvmMain/kotlin/android/text/TextUtils.kt | 26 ++ .../src/jvmMain/kotlin/android/util/Base64.kt | 71 ++++ .../kotlin/android/util/DisplayMetrics.kt | 18 + .../src/jvmMain/kotlin/android/util/Log.kt | 47 +++ .../android/view/ContextThemeWrapper.kt | 11 + .../androidx/preference/PreferenceManager.kt | 17 + .../lagradost/cloudstream3/CloudStreamApp.kt | 106 ++++++ .../lagradost/cloudstream3/CommonActivity.kt | 42 +++ .../lagradost/cloudstream3/MainActivity.kt | 22 ++ .../cloudstream3/network/CloudflareKiller.kt | 42 +++ .../cloudstream3/network/RequestsHelper.kt | 23 ++ .../lagradost/cloudstream3/plugins/Plugin.kt | 26 ++ .../cloudstream3/plugins/PluginManager.kt | 30 ++ .../lagradost/cloudstream3/utils/DataStore.kt | 199 ++++++++++ .../com/lagradost/cloudstream3/utils/Event.kt | 52 +++ .../android/AndroidPluginClassLoader.kt | 67 ++++ .../cloudstream4/android/DesktopAndroid.kt | 58 +++ .../android/DesktopSharedPreferences.kt | 153 ++++++++ .../cloudstream4/android/DexConverter.kt | 144 ++++++++ .../cloudstream4/android/MissingMembers.kt | 131 +++++++ .../cloudstream4/android/StubGenerator.kt | 342 ++++++++++++++++++ .../cloudstream4/android/StubHierarchy.kt | 134 +++++++ desktopApp/build.gradle.kts | 3 +- .../common/THIRD-PARTY-NOTICES.txt | 12 + .../settings/ExtensionsSettings.kt | 171 ++++++--- gradle/libs.versions.toml | 6 + settings.gradle.kts | 2 +- shared/build.gradle.kts | 7 +- .../plugins/DesktopPluginManager.kt | 112 +++++- .../cloudstream4/plugins/PluginLoader.kt | 21 +- .../cloudstream4/plugins/RepositoryClient.kt | 34 +- .../plugins/AndroidExtensionsTest.kt | 163 +++++++++ 43 files changed, 3044 insertions(+), 86 deletions(-) create mode 100644 androidCompat/build.gradle.kts create mode 100644 androidCompat/src/jvmMain/kotlin/android/app/Activity.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/content/Context.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/content/Intent.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/content/SharedPreferences.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/content/pm/PackageManager.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/content/res/Resources.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/net/Uri.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/os/Build.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/os/Bundle.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/os/Handler.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/text/TextUtils.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/util/Base64.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/util/DisplayMetrics.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/util/Log.kt create mode 100644 androidCompat/src/jvmMain/kotlin/android/view/ContextThemeWrapper.kt create mode 100644 androidCompat/src/jvmMain/kotlin/androidx/preference/PreferenceManager.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/RequestsHelper.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopSharedPreferences.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/MissingMembers.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubGenerator.kt create mode 100644 androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt diff --git a/androidCompat/build.gradle.kts b/androidCompat/build.gradle.kts new file mode 100644 index 00000000000..22dda0088a3 --- /dev/null +++ b/androidCompat/build.gradle.kts @@ -0,0 +1,45 @@ +/** + * Lets desktop run extensions that were only built for Android. + * + * It converts an extension's .cs3 (Android dex) into a jar, and provides desktop versions of the + * Android and CloudStream app classes extensions use. Android classes it has no desktop version of + * are generated as empty stubs when an extension loads, see AndroidPluginClassLoader. + */ +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +kotlin { + jvm() + + sourceSets { + all { + languageSettings { + optIn("com.lagradost.cloudstream3.InternalAPI") + optIn("com.lagradost.cloudstream3.Prerelease") + } + } + + jvmMain.dependencies { + api(project(":library")) + // Android has Gson built in through its libraries, extensions use it without bundling it + api(libs.gson) + implementation(libs.jackson.module.kotlin) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.nicehttp) + implementation(libs.okhttp) + } + + jvmTest.dependencies { + implementation(libs.kotlin.test) + } + } +} + +dependencies { + // dex2jar's d2j-external has its own copy of the ASM core classes, the one its command line + // tool runs with, so leave out the separate ASM core to have one copy only + "jvmMainImplementation"(libs.asm.tree) { exclude(group = "org.ow2.asm", module = "asm") } + "jvmMainImplementation"(libs.dex2jar) { exclude(group = "org.ow2.asm", module = "asm") } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/app/Activity.kt b/androidCompat/src/jvmMain/kotlin/android/app/Activity.kt new file mode 100644 index 00000000000..60d149895cd --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/app/Activity.kt @@ -0,0 +1,41 @@ +package android.app + +import android.content.ContextWrapper +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.view.ContextThemeWrapper +import com.lagradost.cloudstream4.android.DesktopAndroid + +/** + * The one activity extensions see on desktop, from CommonActivity.activity. It has no window, so + * extension screens that need one do nothing. + */ +open class Activity : ContextThemeWrapper() { + private var intent: Intent? = Intent(Intent.ACTION_MAIN) + private var requestedOrientation = -1 + + open fun runOnUiThread(action: Runnable?) { + if (action == null) return + if (Looper.getMainLooper().isCurrentThread()) action.run() else Handler(Looper.getMainLooper()).post(action) + } + + open fun getApplication(): Application = DesktopAndroid.application + open fun getIntent(): Intent? = intent + open fun setIntent(newIntent: Intent?) { + intent = newIntent + } + + open fun finish() {} + open fun recreate() {} + open fun isFinishing(): Boolean = false + open fun isDestroyed(): Boolean = false + open fun getRequestedOrientation(): Int = requestedOrientation + open fun setRequestedOrientation(orientation: Int) { + requestedOrientation = orientation + } +} + +open class Application : ContextWrapper() { + open fun onCreate() {} +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/Context.kt b/androidCompat/src/jvmMain/kotlin/android/content/Context.kt new file mode 100644 index 00000000000..cf97ba1d520 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/Context.kt @@ -0,0 +1,85 @@ +package android.content + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.res.Resources +import android.os.Bundle +import android.os.Looper +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Android's Context with the parts extensions use: settings, folders, the package name and opening + * links. Everything else is missing, so an extension calling it fails with NoSuchMethodError only + * when that code runs. + */ +abstract class Context { + open fun getApplicationContext(): Context = DesktopAndroid.application + + open fun getSharedPreferences(name: String?, mode: Int): SharedPreferences = + DesktopAndroid.sharedPreferences(name ?: "null") + + open fun getResources(): Resources = Resources.getSystem() + open fun getPackageName(): String = DesktopAndroid.PACKAGE_NAME + open fun getPackageManager(): PackageManager = PackageManager.instance + open fun getApplicationInfo(): ApplicationInfo = ApplicationInfo.instance + open fun getMainLooper(): Looper = Looper.getMainLooper() + open fun getClassLoader(): ClassLoader = javaClass.classLoader + + open fun getFilesDir(): File = DesktopAndroid.filesDir.toFile() + open fun getCacheDir(): File = DesktopAndroid.cacheDir.toFile() + open fun getDataDir(): File = DesktopAndroid.filesDir.parent.toFile() + open fun getExternalFilesDir(type: String?): File? = if (type == null) getFilesDir() else File(getFilesDir(), type).apply { mkdirs() } + open fun getExternalCacheDir(): File? = getCacheDir() + open fun getDir(name: String?, mode: Int): File = File(getDataDir(), "app_$name").apply { mkdirs() } + open fun openFileInput(name: String?): FileInputStream = FileInputStream(File(getFilesDir(), name ?: "")) + open fun openFileOutput(name: String?, mode: Int): FileOutputStream = + FileOutputStream(File(getFilesDir(), name ?: ""), mode and MODE_APPEND != 0) + open fun deleteFile(name: String?): Boolean = File(getFilesDir(), name ?: "").delete() + + /** Strings come from the app's resources on Android, which desktop does not have */ + open fun getString(resId: Int): String = "" + open fun getString(resId: Int, vararg formatArgs: Any?): String = "" + open fun getColor(id: Int): Int = 0 + + /** No Android services exist on desktop */ + open fun getSystemService(name: String?): Any? = null + + /** Opens web links in the browser, which is what extensions use this for. Other intents are ignored */ + open fun startActivity(intent: Intent?) { + val url = intent?.getData()?.toString() + if (intent?.getAction() == Intent.ACTION_VIEW && url != null && DesktopAndroid.openUrl(url)) return + println("WARNING Context: Ignored startActivity($intent), desktop has no Android activities") + } + + open fun startActivity(intent: Intent?, options: Bundle?) = startActivity(intent) + open fun sendBroadcast(intent: Intent?) {} + + companion object { + const val MODE_PRIVATE = 0 + const val MODE_WORLD_READABLE = 1 + const val MODE_WORLD_WRITEABLE = 2 + const val MODE_APPEND = 32768 + const val ACTIVITY_SERVICE = "activity" + const val CLIPBOARD_SERVICE = "clipboard" + const val CONNECTIVITY_SERVICE = "connectivity" + const val INPUT_METHOD_SERVICE = "input_method" + const val LAYOUT_INFLATER_SERVICE = "layout_inflater" + const val NOTIFICATION_SERVICE = "notification" + const val UI_MODE_SERVICE = "uimode" + const val WINDOW_SERVICE = "window" + } +} + +open class ContextWrapper(private var base: Context?) : Context() { + /** Generated stub subclasses call the no-argument constructor */ + constructor() : this(null) + + open fun getBaseContext(): Context? = base + + protected open fun attachBaseContext(base: Context?) { + this.base = base + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/Intent.kt b/androidCompat/src/jvmMain/kotlin/android/content/Intent.kt new file mode 100644 index 00000000000..e437decaa3d --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/Intent.kt @@ -0,0 +1,103 @@ +package android.content + +import android.net.Uri +import android.os.Bundle + +/** Android's Intent as a plain holder. Context.startActivity opens ACTION_VIEW web links and ignores the rest */ +open class Intent() { + private var action: String? = null + private var data: Uri? = null + private var type: String? = null + private var flags = 0 + private var component: ComponentName? = null + private var packageName: String? = null + private val extras = Bundle() + + constructor(action: String?) : this() { + this.action = action + } + + constructor(action: String?, uri: Uri?) : this(action) { + data = uri + } + + constructor(packageContext: Context?, cls: Class<*>?) : this() { + component = cls?.let { ComponentName(packageContext?.getPackageName(), it.name) } + } + + constructor(other: Intent?) : this() { + if (other != null) { + action = other.action + data = other.data + type = other.type + flags = other.flags + component = other.component + packageName = other.packageName + extras.putAll(other.extras) + } + } + + open fun getAction(): String? = action + open fun setAction(action: String?): Intent = apply { this.action = action } + open fun getData(): Uri? = data + open fun setData(data: Uri?): Intent = apply { this.data = data } + open fun getType(): String? = type + open fun setType(type: String?): Intent = apply { this.type = type } + open fun setDataAndType(data: Uri?, type: String?): Intent = apply { this.data = data; this.type = type } + open fun getFlags(): Int = flags + open fun setFlags(flags: Int): Intent = apply { this.flags = flags } + open fun addFlags(flags: Int): Intent = apply { this.flags = this.flags or flags } + open fun getComponent(): ComponentName? = component + open fun setComponent(component: ComponentName?): Intent = apply { this.component = component } + open fun getPackage(): String? = packageName + open fun setPackage(packageName: String?): Intent = apply { this.packageName = packageName } + open fun setClassName(packageName: String?, className: String?): Intent = + apply { component = ComponentName(packageName, className) } + + open fun getExtras(): Bundle? = extras + open fun putExtras(extras: Bundle?): Intent = apply { extras?.let(this.extras::putAll) } + open fun hasExtra(name: String?): Boolean = extras.containsKey(name) + open fun putExtra(name: String?, value: String?): Intent = apply { extras.putString(name, value) } + open fun putExtra(name: String?, value: Int): Intent = apply { extras.putInt(name, value) } + open fun putExtra(name: String?, value: Long): Intent = apply { extras.putLong(name, value) } + open fun putExtra(name: String?, value: Boolean): Intent = apply { extras.putBoolean(name, value) } + open fun putExtra(name: String?, value: Float): Intent = apply { extras.putFloat(name, value) } + open fun putExtra(name: String?, value: Double): Intent = apply { extras.putDouble(name, value) } + open fun putExtra(name: String?, value: Bundle?): Intent = apply { extras.putBundle(name, value) } + open fun putExtra(name: String?, value: java.io.Serializable?): Intent = apply { extras.putSerializable(name, value) } + open fun getStringExtra(name: String?): String? = extras.getString(name) + open fun getIntExtra(name: String?, defaultValue: Int): Int = extras.getInt(name, defaultValue) + open fun getLongExtra(name: String?, defaultValue: Long): Long = extras.getLong(name, defaultValue) + open fun getBooleanExtra(name: String?, defaultValue: Boolean): Boolean = extras.getBoolean(name, defaultValue) + + override fun toString(): String = "Intent { act=$action dat=$data cmp=$component }" + + companion object { + const val ACTION_VIEW = "android.intent.action.VIEW" + const val ACTION_SEND = "android.intent.action.SEND" + const val ACTION_MAIN = "android.intent.action.MAIN" + const val ACTION_CHOOSER = "android.intent.action.CHOOSER" + const val EXTRA_TEXT = "android.intent.extra.TEXT" + const val EXTRA_TITLE = "android.intent.extra.TITLE" + const val EXTRA_INTENT = "android.intent.extra.INTENT" + const val CATEGORY_BROWSABLE = "android.intent.category.BROWSABLE" + const val FLAG_ACTIVITY_NEW_TASK = 0x10000000 + const val FLAG_ACTIVITY_CLEAR_TASK = 0x00008000 + const val FLAG_ACTIVITY_CLEAR_TOP = 0x04000000 + + /** Extensions use this to restart the app after a settings change, which desktop ignores */ + @JvmStatic + fun makeRestartActivityTask(mainActivity: ComponentName?): Intent = Intent(ACTION_MAIN).setComponent(mainActivity) + + @JvmStatic + fun createChooser(target: Intent?, title: CharSequence?): Intent = Intent(target) + } +} + +open class ComponentName(private val pkg: String?, private val cls: String?) { + constructor(pkg: Context?, cls: Class<*>?) : this(pkg?.getPackageName(), cls?.name) + + open fun getPackageName(): String? = pkg + open fun getClassName(): String? = cls + override fun toString(): String = "ComponentName($pkg/$cls)" +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/SharedPreferences.kt b/androidCompat/src/jvmMain/kotlin/android/content/SharedPreferences.kt new file mode 100644 index 00000000000..158360237c1 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/SharedPreferences.kt @@ -0,0 +1,33 @@ +package android.content + +/** Android's SharedPreferences, see DesktopSharedPreferences for the implementation */ +interface SharedPreferences { + fun getAll(): Map + fun getString(key: String?, defValue: String?): String? + fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? + fun getInt(key: String?, defValue: Int): Int + fun getLong(key: String?, defValue: Long): Long + fun getFloat(key: String?, defValue: Float): Float + fun getBoolean(key: String?, defValue: Boolean): Boolean + fun contains(key: String?): Boolean + fun edit(): Editor + fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener?) + fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener?) + + interface Editor { + fun putString(key: String?, value: String?): Editor + fun putStringSet(key: String?, values: MutableSet?): Editor + fun putInt(key: String?, value: Int): Editor + fun putLong(key: String?, value: Long): Editor + fun putFloat(key: String?, value: Float): Editor + fun putBoolean(key: String?, value: Boolean): Editor + fun remove(key: String?): Editor + fun clear(): Editor + fun commit(): Boolean + fun apply() + } + + fun interface OnSharedPreferenceChangeListener { + fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/pm/PackageManager.kt b/androidCompat/src/jvmMain/kotlin/android/content/pm/PackageManager.kt new file mode 100644 index 00000000000..cfd7537329a --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/pm/PackageManager.kt @@ -0,0 +1,41 @@ +package android.content.pm + +import android.content.ComponentName +import android.content.Intent +import com.lagradost.cloudstream4.android.DesktopAndroid + +/** Android's PackageManager for the app restart and feature checks extensions do. Desktop has no other packages */ +open class PackageManager { + /** Only the app itself is found, so restart code gets an intent, which startActivity then ignores */ + open fun getLaunchIntentForPackage(packageName: String?): Intent? = + if (packageName == DesktopAndroid.PACKAGE_NAME) { + Intent(Intent.ACTION_MAIN).setComponent(ComponentName(packageName, "$packageName.MainActivity")) + } else null + + open fun hasSystemFeature(name: String?): Boolean = false + + open class NameNotFoundException(name: String?) : Exception(name) { + constructor() : this(null) + } + + companion object { + const val FEATURE_LEANBACK = "android.software.leanback" + const val FEATURE_TOUCHSCREEN = "android.hardware.touchscreen" + const val PERMISSION_GRANTED = 0 + const val PERMISSION_DENIED = -1 + + internal val instance = PackageManager() + } +} + +open class ApplicationInfo { + @JvmField var packageName: String? = DesktopAndroid.PACKAGE_NAME + @JvmField var dataDir: String? = DesktopAndroid.filesDir.parent.toString() + @JvmField var flags = 0 + @JvmField var targetSdkVersion = 35 + @JvmField var minSdkVersion = 21 + + companion object { + internal val instance by lazy { ApplicationInfo() } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/content/res/Resources.kt b/androidCompat/src/jvmMain/kotlin/android/content/res/Resources.kt new file mode 100644 index 00000000000..8bc358f8aea --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/content/res/Resources.kt @@ -0,0 +1,59 @@ +package android.content.res + +import android.util.DisplayMetrics + +/** + * Android's Resources for the screen metrics and configuration extensions read. Desktop has no app + * resources, so lookups find nothing: identifiers are 0 and strings are empty. + */ +open class Resources { + private val metrics = DisplayMetrics() + private val configuration = Configuration() + + open fun getDisplayMetrics(): DisplayMetrics = metrics + open fun getConfiguration(): Configuration = configuration + open fun getIdentifier(name: String?, defType: String?, defPackage: String?): Int = 0 + open fun getString(id: Int): String = "" + open fun getString(id: Int, vararg formatArgs: Any?): String = "" + open fun getColor(id: Int): Int = 0 + open fun getDimension(id: Int): Float = 0f + open fun getDimensionPixelSize(id: Int): Int = 0 + open fun getBoolean(id: Int): Boolean = false + open fun getInteger(id: Int): Int = 0 + + open class NotFoundException(name: String?) : RuntimeException(name) { + constructor() : this(null) + } + + companion object { + private val system = Resources() + + @JvmStatic + fun getSystem(): Resources = system + } +} + +open class Configuration { + @JvmField var uiMode = UI_MODE_TYPE_NORMAL or UI_MODE_NIGHT_YES + @JvmField var orientation = ORIENTATION_LANDSCAPE + @JvmField var screenLayout = SCREENLAYOUT_SIZE_LARGE + @JvmField var fontScale = 1f + @JvmField var densityDpi = 160 + @JvmField var screenWidthDp = 1280 + @JvmField var screenHeightDp = 720 + @JvmField var smallestScreenWidthDp = 720 + @JvmField var locale: java.util.Locale = java.util.Locale.getDefault() + + companion object { + const val UI_MODE_TYPE_MASK = 0x0f + const val UI_MODE_TYPE_NORMAL = 0x01 + const val UI_MODE_TYPE_TELEVISION = 0x04 + const val UI_MODE_NIGHT_MASK = 0x30 + const val UI_MODE_NIGHT_NO = 0x10 + const val UI_MODE_NIGHT_YES = 0x20 + const val ORIENTATION_PORTRAIT = 1 + const val ORIENTATION_LANDSCAPE = 2 + const val SCREENLAYOUT_SIZE_MASK = 0x0f + const val SCREENLAYOUT_SIZE_LARGE = 0x03 + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/net/Uri.kt b/androidCompat/src/jvmMain/kotlin/android/net/Uri.kt new file mode 100644 index 00000000000..73cf35e3140 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/net/Uri.kt @@ -0,0 +1,187 @@ +package android.net + +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.charset.StandardCharsets + +/** + * Android's Uri. Like Android, parsing never fails: the string is split with the RFC 3986 regex, + * and getters decode on the way out. + */ +open class Uri private constructor(private val uriString: String) : Comparable { + private val parts by lazy { URI_REGEX.matchEntire(uriString) } + private fun part(group: Int): String? = parts?.groups?.get(group)?.value + + open fun getScheme(): String? = part(2) + open fun getEncodedAuthority(): String? = part(4) + open fun getAuthority(): String? = getEncodedAuthority()?.let { decode(it) } + open fun getEncodedPath(): String? = part(5) + open fun getPath(): String? = getEncodedPath()?.let { decode(it) } + open fun getEncodedQuery(): String? = part(7) + open fun getQuery(): String? = getEncodedQuery()?.let { decode(it) } + open fun getEncodedFragment(): String? = part(9) + open fun getFragment(): String? = getEncodedFragment()?.let { decode(it) } + open fun getSchemeSpecificPart(): String? = uriString.substringAfter(':', uriString).substringBefore('#') + open fun isAbsolute(): Boolean = getScheme() != null + open fun isRelative(): Boolean = !isAbsolute() + open fun isHierarchical(): Boolean = !isAbsolute() || getSchemeSpecificPart()?.startsWith("/") == true + + open fun getUserInfo(): String? = getEncodedAuthority()?.takeIf { '@' in it }?.substringBefore('@')?.let { decode(it) } + + open fun getHost(): String? { + val hostPort = getEncodedAuthority()?.substringAfterLast('@') ?: return null + val host = if (hostPort.startsWith("[")) hostPort.substringBefore(']') + "]" else hostPort.substringBefore(':') + return decode(host)?.ifEmpty { null } + } + + open fun getPort(): Int { + val hostPort = getEncodedAuthority()?.substringAfterLast('@') ?: return -1 + return hostPort.substringAfter(']', hostPort).substringAfter(':', "").toIntOrNull() ?: -1 + } + + open fun getPathSegments(): List = + getEncodedPath()?.split('/')?.filter { it.isNotEmpty() }?.map { decode(it)!! }.orEmpty() + + open fun getLastPathSegment(): String? = getPathSegments().lastOrNull() + + private fun queryPairs(): List> = getEncodedQuery()?.split('&')?.filter { it.isNotEmpty() }?.map { + percentDecode(it.substringBefore('='), plusAsSpace = true) to percentDecode(it.substringAfter('=', ""), plusAsSpace = true) + }.orEmpty() + + /** Like Android, "+" in the query reads as a space */ + open fun getQueryParameter(key: String?): String? = queryPairs().firstOrNull { it.first == key }?.second + open fun getQueryParameters(key: String?): List = queryPairs().filter { it.first == key }.map { it.second } + open fun getQueryParameterNames(): Set = queryPairs().mapTo(LinkedHashSet()) { it.first } + open fun getBooleanQueryParameter(key: String?, defaultValue: Boolean): Boolean = + getQueryParameter(key)?.let { it != "false" && it != "0" } ?: defaultValue + + open fun buildUpon(): Builder = Builder() + .scheme(getScheme()) + .encodedAuthority(getEncodedAuthority()) + .encodedPath(getEncodedPath()) + .encodedQuery(getEncodedQuery()) + .encodedFragment(getEncodedFragment()) + + open fun normalizeScheme(): Uri = + getScheme()?.let { scheme -> parse(scheme.lowercase() + uriString.substring(scheme.length)) } ?: this + + override fun toString(): String = uriString + override fun equals(other: Any?): Boolean = other is Uri && other.uriString == uriString + override fun hashCode(): Int = uriString.hashCode() + override fun compareTo(other: Uri): Int = uriString.compareTo(other.uriString) + + open class Builder { + private var scheme: String? = null + private var authority: String? = null + private var path: String? = null + private var query: String? = null + private var fragment: String? = null + + open fun scheme(scheme: String?): Builder = apply { this.scheme = scheme } + open fun authority(authority: String?): Builder = apply { this.authority = encode(authority, "@:[]") } + open fun encodedAuthority(authority: String?): Builder = apply { this.authority = authority } + open fun path(path: String?): Builder = apply { this.path = encode(path, "/") } + open fun encodedPath(path: String?): Builder = apply { this.path = path } + open fun appendPath(segment: String?): Builder = appendEncodedPath(encode(segment)) + open fun appendEncodedPath(segment: String?): Builder = apply { + if (segment == null) return@apply + val current = path.orEmpty() + path = if (current.endsWith("/")) current + segment.removePrefix("/") else current + "/" + segment.removePrefix("/") + } + + open fun query(query: String?): Builder = apply { this.query = encode(query, "=&") } + open fun encodedQuery(query: String?): Builder = apply { this.query = query } + open fun appendQueryParameter(key: String?, value: String?): Builder = apply { + val pair = encode(key.orEmpty()) + "=" + encode(value.orEmpty()) + query = if (query.isNullOrEmpty()) pair else "$query&$pair" + } + + open fun clearQuery(): Builder = apply { query = null } + open fun fragment(fragment: String?): Builder = apply { this.fragment = encode(fragment) } + open fun encodedFragment(fragment: String?): Builder = apply { this.fragment = fragment } + + open fun build(): Uri = parse(buildString { + scheme?.let { append(it).append(':') } + authority?.let { append("//").append(it) } + path?.let { + if (authority != null && it.isNotEmpty() && !it.startsWith("/")) append('/') + append(it) + } + query?.let { append('?').append(it) } + fragment?.let { append('#').append(it) } + }) + + override fun toString(): String = build().toString() + } + + companion object { + /** RFC 3986 appendix B */ + private val URI_REGEX = Regex("""^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?""", RegexOption.DOT_MATCHES_ALL) + private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-!.~'()*" + + @JvmField + val EMPTY: Uri = Uri("") + + @JvmStatic + fun parse(uriString: String?): Uri = Uri(uriString ?: throw NullPointerException("uriString")) + + @JvmStatic + fun fromFile(file: File?): Uri { + val path = file!!.absolutePath.replace('\\', '/') + return Uri("file://" + encode(if (path.startsWith("/")) path else "/$path", "/:")) + } + + @JvmStatic + fun fromParts(scheme: String?, ssp: String?, fragment: String?): Uri = + Uri(scheme + ":" + encode(ssp, "/:@") + (fragment?.let { "#" + encode(it) } ?: "")) + + @JvmStatic + fun withAppendedPath(baseUri: Uri?, pathSegment: String?): Uri = baseUri!!.buildUpon().appendEncodedPath(pathSegment).build() + + @JvmStatic + fun encode(s: String?): String? = encode(s, null) + + /** Percent-encodes everything but letters, digits, "_-!.~'()*" and the allowed characters */ + @JvmStatic + fun encode(s: String?, allow: String?): String? { + if (s == null) return null + val out = StringBuilder() + for (b in s.toByteArray(StandardCharsets.UTF_8)) { + val c = (b.toInt() and 0xff).toChar() + if (b >= 0 && (c in UNRESERVED || (allow != null && c in allow))) out.append(c) + else out.append('%').append("%02X".format(b.toInt() and 0xff)) + } + return out.toString() + } + + @JvmStatic + fun decode(s: String?): String? = s?.let { percentDecode(it, plusAsSpace = false) } + + /** Like Android's UriCodec, an invalid escape is kept as it is rather than failing */ + private fun percentDecode(s: String, plusAsSpace: Boolean): String { + if ('%' !in s && !(plusAsSpace && '+' in s)) return s + val out = StringBuilder() + val bytes = ByteArrayOutputStream() + fun flush() { + if (bytes.size() > 0) out.append(bytes.toByteArray().toString(StandardCharsets.UTF_8)).also { bytes.reset() } + } + var i = 0 + while (i < s.length) { + val c = s[i] + val hex = if (c == '%' && i + 2 < s.length) s.substring(i + 1, i + 3).takeIf { it.all(::isHexDigit) }?.toInt(16) else null + if (hex != null) { + bytes.write(hex) + i += 3 + } else { + flush() + out.append(if (c == '+' && plusAsSpace) ' ' else c) + i++ + } + } + flush() + return out.toString() + } + + private fun isHexDigit(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/os/Build.kt b/androidCompat/src/jvmMain/kotlin/android/os/Build.kt new file mode 100644 index 00000000000..c94b7f333ce --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/os/Build.kt @@ -0,0 +1,66 @@ +package android.os + +/** Describes a recent Android phone, so extensions take their modern code paths */ +open class Build { + open class VERSION { + companion object { + @JvmField val SDK_INT: Int = 35 + @JvmField val RELEASE: String = "15" + @JvmField val CODENAME: String = "REL" + @JvmField val INCREMENTAL: String = "desktop" + @JvmField val PREVIEW_SDK_INT: Int = 0 + } + } + + open class VERSION_CODES { + companion object { + const val LOLLIPOP = 21 + const val M = 23 + const val N = 24 + const val O = 26 + const val P = 28 + const val Q = 29 + const val R = 30 + const val S = 31 + const val TIRAMISU = 33 + const val UPSIDE_DOWN_CAKE = 34 + const val VANILLA_ICE_CREAM = 35 + } + } + + companion object { + @JvmField val MANUFACTURER: String = "CloudStream" + @JvmField val BRAND: String = "CloudStream" + @JvmField val MODEL: String = "Desktop" + @JvmField val DEVICE: String = "desktop" + @JvmField val PRODUCT: String = "desktop" + @JvmField val HARDWARE: String = "desktop" + @JvmField val BOARD: String = "desktop" + @JvmField val HOST: String = "desktop" + @JvmField val ID: String = "desktop" + @JvmField val TYPE: String = "user" + @JvmField val TAGS: String = "release-keys" + @JvmField val FINGERPRINT: String = "CloudStream/desktop/desktop:15/desktop:user/release-keys" + @JvmField val SUPPORTED_ABIS: Array = arrayOf("x86_64") + @JvmField val UNKNOWN: String = "unknown" + + @JvmStatic + fun getSerial(): String = UNKNOWN + } +} + +class SystemClock private constructor() { + companion object { + private val start = System.nanoTime() + + /** Android counts these from boot, desktop from when the app started */ + @JvmStatic fun uptimeMillis(): Long = (System.nanoTime() - start) / 1_000_000 + @JvmStatic fun elapsedRealtime(): Long = uptimeMillis() + @JvmStatic fun elapsedRealtimeNanos(): Long = System.nanoTime() - start + @JvmStatic fun currentThreadTimeMillis(): Long = uptimeMillis() + @JvmStatic fun sleep(ms: Long) = try { + Thread.sleep(ms) + } catch (_: InterruptedException) { + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/os/Bundle.kt b/androidCompat/src/jvmMain/kotlin/android/os/Bundle.kt new file mode 100644 index 00000000000..ec83e217019 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/os/Bundle.kt @@ -0,0 +1,71 @@ +package android.os + +import java.io.Serializable + +/** Android's Bundle as a map. Extensions use it for fragment arguments and intent extras */ +open class Bundle() { + private val map = LinkedHashMap() + + constructor(capacity: Int) : this() + + constructor(other: Bundle?) : this() { + other?.let(::putAll) + } + + open fun size(): Int = map.size + open fun isEmpty(): Boolean = map.isEmpty() + open fun clear() = map.clear() + open fun containsKey(key: String?): Boolean = map.containsKey(key) + open fun remove(key: String?) { + map.remove(key) + } + + open fun keySet(): Set = map.keys + open fun putAll(bundle: Bundle?) { + bundle?.let { map.putAll(it.map) } + } + + open fun get(key: String?): Any? = map[key] + + private inline fun typed(key: String?): T? = map[key] as? T + + open fun putString(key: String?, value: String?) = map.set(key, value) + open fun getString(key: String?): String? = typed(key) + open fun getString(key: String?, defaultValue: String?): String? = typed(key) ?: defaultValue + open fun putCharSequence(key: String?, value: CharSequence?) = map.set(key, value) + open fun getCharSequence(key: String?): CharSequence? = typed(key) + open fun putInt(key: String?, value: Int) = map.set(key, value) + open fun getInt(key: String?): Int = getInt(key, 0) + open fun getInt(key: String?, defaultValue: Int): Int = typed(key) ?: defaultValue + open fun putLong(key: String?, value: Long) = map.set(key, value) + open fun getLong(key: String?): Long = getLong(key, 0L) + open fun getLong(key: String?, defaultValue: Long): Long = typed(key) ?: defaultValue + open fun putFloat(key: String?, value: Float) = map.set(key, value) + open fun getFloat(key: String?): Float = getFloat(key, 0f) + open fun getFloat(key: String?, defaultValue: Float): Float = typed(key) ?: defaultValue + open fun putDouble(key: String?, value: Double) = map.set(key, value) + open fun getDouble(key: String?): Double = getDouble(key, 0.0) + open fun getDouble(key: String?, defaultValue: Double): Double = typed(key) ?: defaultValue + open fun putBoolean(key: String?, value: Boolean) = map.set(key, value) + open fun getBoolean(key: String?): Boolean = getBoolean(key, false) + open fun getBoolean(key: String?, defaultValue: Boolean): Boolean = typed(key) ?: defaultValue + open fun putBundle(key: String?, value: Bundle?) = map.set(key, value) + open fun getBundle(key: String?): Bundle? = typed(key) + open fun putSerializable(key: String?, value: Serializable?) = map.set(key, value) + open fun getSerializable(key: String?): Serializable? = typed(key) + open fun putStringArrayList(key: String?, value: ArrayList?) = map.set(key, value) + + @Suppress("UNCHECKED_CAST") + open fun getStringArrayList(key: String?): ArrayList? = map[key] as? ArrayList + open fun putStringArray(key: String?, value: Array?) = map.set(key, value) + + @Suppress("UNCHECKED_CAST") + open fun getStringArray(key: String?): Array? = map[key] as? Array + + override fun toString(): String = "Bundle$map" + + companion object { + @JvmField + val EMPTY = Bundle() + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/os/Handler.kt b/androidCompat/src/jvmMain/kotlin/android/os/Handler.kt new file mode 100644 index 00000000000..05da414f556 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/os/Handler.kt @@ -0,0 +1,107 @@ +package android.os + +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * Android's Looper as a single thread that runs posted work in order. The main looper is a thread + * of its own, not the desktop UI thread, because extensions only use it for timers and callbacks. + */ +class Looper private constructor(name: String) { + @Volatile + private var thread: Thread? = null + + internal val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, name).apply { + isDaemon = true + thread = this + } + } + + fun getThread(): Thread? = thread + fun isCurrentThread(): Boolean = Thread.currentThread() == thread + fun quit() = executor.shutdownNow().let { } + fun quitSafely() = executor.shutdown() + + companion object { + private val main = Looper("Android main looper") + private val current = ThreadLocal() + + @JvmStatic + fun getMainLooper(): Looper = main + + /** The looper of this thread: the main looper on its own thread, or one made by prepare() */ + @JvmStatic + fun myLooper(): Looper? = if (main.isCurrentThread()) main else current.get() + + @JvmStatic + fun prepare() { + if (current.get() == null) current.set(Looper("Android looper ${Thread.currentThread().name}")) + } + + @JvmStatic + fun prepareMainLooper() {} + + /** Work runs on the looper's own thread, so there is nothing to wait for here */ + @JvmStatic + fun loop() {} + } +} + +open class Handler(private val looper: Looper) { + /** Android needs a looper on this thread, desktop falls back to the main looper */ + constructor() : this(Looper.myLooper() ?: Looper.getMainLooper()) + + private class Posted(val runnable: Runnable, val token: Any?, val future: ScheduledFuture<*>) + + private val posted = mutableListOf() + + open fun getLooper(): Looper = looper + + open fun post(r: Runnable?): Boolean = postDelayed(r, 0L) + + open fun postDelayed(r: Runnable?, delayMillis: Long): Boolean = postDelayed(r, null, delayMillis) + + open fun postDelayed(r: Runnable?, token: Any?, delayMillis: Long): Boolean { + if (r == null) return false + return try { + synchronized(posted) { + lateinit var entry: Posted + val future = looper.executor.schedule({ + synchronized(posted) { posted.remove(entry) } + try { + r.run() + } catch (t: Throwable) { + println("ERROR Handler: Posted work failed: $t") + t.printStackTrace() + } + }, maxOf(0L, delayMillis), TimeUnit.MILLISECONDS) + entry = Posted(r, token, future) + posted += entry + } + true + } catch (_: java.util.concurrent.RejectedExecutionException) { + false + } + } + + open fun postAtFrontOfQueue(r: Runnable?): Boolean = post(r) + + open fun hasCallbacks(r: Runnable?): Boolean = synchronized(posted) { posted.any { it.runnable == r } } + + open fun removeCallbacks(r: Runnable?) = remove { it.runnable == r } + + open fun removeCallbacks(r: Runnable?, token: Any?) = remove { it.runnable == r && (token == null || it.token == token) } + + /** A null token removes everything, like Android */ + open fun removeCallbacksAndMessages(token: Any?) = remove { token == null || it.token == token } + + private fun remove(match: (Posted) -> Boolean) = synchronized(posted) { + val removed = posted.filter(match) + removed.forEach { it.future.cancel(false) } + posted.removeAll(removed) + Unit + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/text/TextUtils.kt b/androidCompat/src/jvmMain/kotlin/android/text/TextUtils.kt new file mode 100644 index 00000000000..4eae3a6c580 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/text/TextUtils.kt @@ -0,0 +1,26 @@ +package android.text + +class TextUtils private constructor() { + companion object { + @JvmStatic fun isEmpty(str: CharSequence?): Boolean = str.isNullOrEmpty() + @JvmStatic fun isDigitsOnly(str: CharSequence?): Boolean = str!!.all { it.isDigit() } + @JvmStatic fun equals(a: CharSequence?, b: CharSequence?): Boolean = a?.toString() == b?.toString() + @JvmStatic fun join(delimiter: CharSequence?, tokens: Array?): String = tokens!!.joinToString(delimiter.toString()) + @JvmStatic fun join(delimiter: CharSequence?, tokens: Iterable<*>?): String = tokens!!.joinToString(delimiter.toString()) + @JvmStatic fun split(text: String?, expression: String?): Array = + if (text.isNullOrEmpty()) emptyArray() else text.split(Regex(expression!!)).toTypedArray() + @JvmStatic fun getTrimmedLength(s: CharSequence?): Int = s!!.trim().length + + @JvmStatic + fun htmlEncode(s: String?): String = buildString { + for (c in s!!) when (c) { + '<' -> append("<") + '>' -> append(">") + '&' -> append("&") + '\'' -> append("'") + '"' -> append(""") + else -> append(c) + } + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/util/Base64.kt b/androidCompat/src/jvmMain/kotlin/android/util/Base64.kt new file mode 100644 index 00000000000..ecd52f0039e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/util/Base64.kt @@ -0,0 +1,71 @@ +package android.util + +/** + * Android's Base64, which differs from java.util.Base64 in ways extensions depend on: DEFAULT + * encoding wraps lines at 76 characters and ends with a newline, and decoding skips characters + * outside the alphabet (such as whitespace) and accepts missing padding. + */ +class Base64 private constructor() { + companion object { + const val DEFAULT = 0 + const val NO_PADDING = 1 + const val NO_WRAP = 2 + const val CRLF = 4 + const val URL_SAFE = 8 + const val NO_CLOSE = 16 + + private const val LINE_LENGTH = 76 + + @JvmStatic + fun decode(str: String?, flags: Int): ByteArray = decode(str!!.toByteArray(Charsets.US_ASCII), flags) + + @JvmStatic + fun decode(input: ByteArray?, flags: Int): ByteArray = decode(input, 0, input!!.size, flags) + + @JvmStatic + fun decode(input: ByteArray?, offset: Int, len: Int, flags: Int): ByteArray { + val urlSafe = flags and URL_SAFE != 0 + val data = StringBuilder(len) + var padding = false + for (i in offset until offset + len) { + val c = (input!![i].toInt() and 0xff).toChar() + when { + c == '=' -> padding = true + c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || + (urlSafe && (c == '-' || c == '_')) || (!urlSafe && (c == '+' || c == '/')) -> { + if (padding) throw IllegalArgumentException("bad base-64") + data.append(if (c == '-') '+' else if (c == '_') '/' else c) + } + // Android skips every other character + } + } + if (data.length % 4 == 1) throw IllegalArgumentException("bad base-64") + while (data.length % 4 != 0) data.append('=') + return java.util.Base64.getDecoder().decode(data.toString()) + } + + @JvmStatic + fun encodeToString(input: ByteArray?, flags: Int): String = String(encode(input, flags), Charsets.US_ASCII) + + @JvmStatic + fun encodeToString(input: ByteArray?, offset: Int, len: Int, flags: Int): String = + String(encode(input, offset, len, flags), Charsets.US_ASCII) + + @JvmStatic + fun encode(input: ByteArray?, flags: Int): ByteArray = encode(input, 0, input!!.size, flags) + + @JvmStatic + fun encode(input: ByteArray?, offset: Int, len: Int, flags: Int): ByteArray { + var encoder = if (flags and URL_SAFE != 0) java.util.Base64.getUrlEncoder() else java.util.Base64.getEncoder() + if (flags and NO_PADDING != 0) encoder = encoder.withoutPadding() + val text = encoder.encodeToString(input!!.copyOfRange(offset, offset + len)) + if (flags and NO_WRAP != 0) return text.toByteArray(Charsets.US_ASCII) + val newline = if (flags and CRLF != 0) "\r\n" else "\n" + val wrapped = StringBuilder() + for (start in text.indices step LINE_LENGTH) { + wrapped.append(text, start, minOf(text.length, start + LINE_LENGTH)).append(newline) + } + return wrapped.toString().toByteArray(Charsets.US_ASCII) + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/util/DisplayMetrics.kt b/androidCompat/src/jvmMain/kotlin/android/util/DisplayMetrics.kt new file mode 100644 index 00000000000..8883deecad3 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/util/DisplayMetrics.kt @@ -0,0 +1,18 @@ +package android.util + +/** A 1280x720 screen at Android's baseline density, so dp and px are the same */ +open class DisplayMetrics { + @JvmField var widthPixels = 1280 + @JvmField var heightPixels = 720 + @JvmField var density = 1f + @JvmField var densityDpi = 160 + @JvmField var scaledDensity = 1f + @JvmField var xdpi = 160f + @JvmField var ydpi = 160f + + open fun setToDefaults() {} + + companion object { + const val DENSITY_DEFAULT = 160 + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/util/Log.kt b/androidCompat/src/jvmMain/kotlin/android/util/Log.kt new file mode 100644 index 00000000000..35dbee4c95e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/util/Log.kt @@ -0,0 +1,47 @@ +package android.util + +import java.io.PrintWriter +import java.io.StringWriter + +/** Android's Log, printed in the app's "LEVEL tag: message" format */ +class Log private constructor() { + companion object { + const val VERBOSE = 2 + const val DEBUG = 3 + const val INFO = 4 + const val WARN = 5 + const val ERROR = 6 + const val ASSERT = 7 + + private fun write(level: String, tag: String?, msg: String?, tr: Throwable? = null): Int { + val line = "$level $tag: $msg" + (tr?.let { "\n" + getStackTraceString(it) } ?: "") + kotlin.io.println(line) + return line.length + } + + @JvmStatic fun v(tag: String?, msg: String?): Int = write("VERBOSE", tag, msg) + @JvmStatic fun v(tag: String?, msg: String?, tr: Throwable?): Int = write("VERBOSE", tag, msg, tr) + @JvmStatic fun d(tag: String?, msg: String?): Int = write("DEBUG", tag, msg) + @JvmStatic fun d(tag: String?, msg: String?, tr: Throwable?): Int = write("DEBUG", tag, msg, tr) + @JvmStatic fun i(tag: String?, msg: String?): Int = write("INFO", tag, msg) + @JvmStatic fun i(tag: String?, msg: String?, tr: Throwable?): Int = write("INFO", tag, msg, tr) + @JvmStatic fun w(tag: String?, msg: String?): Int = write("WARNING", tag, msg) + @JvmStatic fun w(tag: String?, msg: String?, tr: Throwable?): Int = write("WARNING", tag, msg, tr) + @JvmStatic fun w(tag: String?, tr: Throwable?): Int = write("WARNING", tag, "", tr) + @JvmStatic fun e(tag: String?, msg: String?): Int = write("ERROR", tag, msg) + @JvmStatic fun e(tag: String?, msg: String?, tr: Throwable?): Int = write("ERROR", tag, msg, tr) + @JvmStatic fun wtf(tag: String?, msg: String?): Int = write("ERROR", tag, msg) + @JvmStatic fun wtf(tag: String?, tr: Throwable?): Int = write("ERROR", tag, "", tr) + @JvmStatic fun wtf(tag: String?, msg: String?, tr: Throwable?): Int = write("ERROR", tag, msg, tr) + @JvmStatic fun println(priority: Int, tag: String?, msg: String?): Int = write("LOG$priority", tag, msg) + @JvmStatic fun isLoggable(tag: String?, level: Int): Boolean = level >= INFO + + @JvmStatic + fun getStackTraceString(tr: Throwable?): String { + if (tr == null) return "" + val out = StringWriter() + tr.printStackTrace(PrintWriter(out)) + return out.toString() + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/android/view/ContextThemeWrapper.kt b/androidCompat/src/jvmMain/kotlin/android/view/ContextThemeWrapper.kt new file mode 100644 index 00000000000..5d50eecfe16 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/view/ContextThemeWrapper.kt @@ -0,0 +1,11 @@ +package android.view + +import android.content.Context +import android.content.ContextWrapper + +open class ContextThemeWrapper(base: Context?) : ContextWrapper(base) { + /** Generated stub subclasses call the no-argument constructor */ + constructor() : this(null) + + constructor(base: Context?, themeResId: Int) : this(base) +} diff --git a/androidCompat/src/jvmMain/kotlin/androidx/preference/PreferenceManager.kt b/androidCompat/src/jvmMain/kotlin/androidx/preference/PreferenceManager.kt new file mode 100644 index 00000000000..012c7dcda3e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/preference/PreferenceManager.kt @@ -0,0 +1,17 @@ +package androidx.preference + +import android.content.Context +import android.content.SharedPreferences + +/** Only the default preferences lookup, which extensions call outside their settings screens too */ +open class PreferenceManager { + companion object { + @JvmStatic + fun getDefaultSharedPreferencesName(context: Context?): String = (context?.getPackageName() ?: "app") + "_preferences" + + @JvmStatic + fun getDefaultSharedPreferences(context: Context?): SharedPreferences = + (context ?: com.lagradost.cloudstream4.android.DesktopAndroid.application) + .getSharedPreferences(getDefaultSharedPreferencesName(context), Context.MODE_PRIVATE) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt new file mode 100644 index 00000000000..015866e6f85 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,106 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.removeKeys +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid + +/** The Android app's Application, for the context and settings helpers in its companion */ +class CloudStreamApp : Application() { + companion object { + /** Use to get Activity from Context. */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> { + val base = getBaseContext() ?: return null + if (base === this) null else base.getActivity() + } + else -> null + } + } + + val context: Context? get() = DesktopAndroid.application + + fun getKeyClass(path: String, valueType: Class): T? { + return context?.getKey(path, valueType) + } + + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + + fun removeKeys(folder: String): Int? { + return context?.removeKeys(folder) + } + + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } + + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } + + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } + + inline fun getKey(path: String): T? { + return context?.getKey(path) + } + + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } + + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } + + fun getKeys(folder: String): List? { + return context?.getKeys(folder) + } + + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + + fun removeKey(path: String) { + context?.removeKey(path) + } + + /** Desktop has no WebView, so the link always opens in the browser */ + fun openBrowser(url: String, activity: Activity?) { + DesktopAndroid.openUrl(url) + } + } +} + +/** Deprecated alias for CloudStreamApp that older extensions still use */ +class AcraApplication { + companion object { + val context get() = CloudStreamApp.context + + fun removeKeys(folder: String): Int? = CloudStreamApp.removeKeys(folder) + + fun setKey(path: String, value: T) = CloudStreamApp.setKey(path, value) + + fun setKey(folder: String, path: String, value: T) = CloudStreamApp.setKey(folder, path, value) + + inline fun getKey(path: String, defVal: T?): T? = CloudStreamApp.getKey(path, defVal) + + inline fun getKey(path: String): T? = CloudStreamApp.getKey(path) + + inline fun getKey(folder: String, path: String): T? = CloudStreamApp.getKey(folder, path) + + inline fun getKey(folder: String, path: String, defVal: T?): T? = + CloudStreamApp.getKey(folder, path, defVal) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt new file mode 100644 index 00000000000..b06d2d1ed9e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/CommonActivity.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.content.res.Resources +import android.util.DisplayMetrics +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlin.math.max +import kotlin.math.min + +/** The Android app's CommonActivity, for the activity and toasts extensions use */ +object CommonActivity { + val activity: Activity? get() = DesktopAndroid.activity + + fun setActivityInstance(newActivity: Activity?) {} + + val displayMetrics: DisplayMetrics = Resources.getSystem().getDisplayMetrics() + + val screenWidth: Int get() = max(displayMetrics.widthPixels, displayMetrics.heightPixels) + val screenHeight: Int get() = min(displayMetrics.widthPixels, displayMetrics.heightPixels) + val screenWidthWithOrientation: Int get() = displayMetrics.widthPixels + val screenHeightWithOrientation: Int get() = displayMetrics.heightPixels + + var isPipDesired: Boolean = false + var isInPIPMode: Boolean = false + var appliedTheme: Int = 0 + var appliedColor: Int = 0 + + const val TAG = "COMPACT" + + /** Desktop has no string resources, so these show nothing */ + fun showToast(message: Int, duration: Int? = null) {} + + fun showToast(message: String?, duration: Int? = null) { + message?.let(DesktopAndroid::showToast) + } + + fun showToast(act: Activity?, message: Int, duration: Int? = null) {} + + fun showToast(act: Activity?, message: String?, duration: Int? = null) { + message?.let(DesktopAndroid::showToast) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt new file mode 100644 index 00000000000..a2d2ab4f738 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -0,0 +1,22 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import com.lagradost.cloudstream3.utils.Event + +/** + * The Android app's MainActivity, for the events in its companion that extensions subscribe to. + * The desktop app fires afterPluginsLoadedEvent and mainPluginsLoadedEvent after loading extensions. + */ +class MainActivity : Activity() { + companion object { + const val TAG = "MAINACT" + + val afterPluginsLoadedEvent = Event() + val mainPluginsLoadedEvent = Event() + val afterRepositoryLoadedEvent = Event() + val bookmarksUpdatedEvent = Event() + val reloadHomeEvent = Event() + val reloadLibraryEvent = Event() + val reloadAccountEvent = Event() + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt new file mode 100644 index 00000000000..b01f65daf8a --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.network + +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Response +import java.net.URI + +/** + * The Android app's CloudflareKiller. On Android it solves Cloudflare's check in a WebView, which + * desktop does not have, so here it only adds cookies saved for the host and otherwise passes + * requests through unchanged. Sites behind the check fail as they would without it. + */ +class CloudflareKiller : Interceptor { + companion object { + const val TAG = "CloudflareKiller" + + fun parseCookieMap(cookie: String): Map { + return cookie.split(";").associate { + val split = it.split("=") + (split.getOrNull(0)?.trim() ?: "") to (split.getOrNull(1)?.trim() ?: "") + }.filter { it.key.isNotBlank() && it.value.isNotBlank() } + } + } + + val savedCookies: MutableMap> = mutableMapOf() + + /** Gets the headers with the saved cookies */ + fun getCookieHeaders(url: String): Headers { + val userAgentHeaders = WebViewResolver.webViewUserAgent?.let { + mapOf("user-agent" to it) + } ?: emptyMap() + + return getHeaders(userAgentHeaders, savedCookies[URI(url).host] ?: emptyMap()) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val cookies = savedCookies[request.url.host] ?: return chain.proceed(request) + val cookieHeader = cookies.entries.joinToString(" ") { "${it.key}=${it.value};" } + return chain.proceed(request.newBuilder().header("Cookie", cookieHeader).build()) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/RequestsHelper.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/RequestsHelper.kt new file mode 100644 index 00000000000..646e469766a --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -0,0 +1,23 @@ +package com.lagradost.cloudstream3.network + +import com.lagradost.cloudstream3.USER_AGENT +import okhttp3.Headers +import okhttp3.Headers.Companion.toHeaders + +// From the Android app's RequestsHelper.kt, which extensions call for CloudflareKiller headers + +private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) + +/** Set headers > Set cookies > Default headers > Default Cookies */ +fun getHeaders( + headers: Map, + cookie: Map +): Headers { + val cookieMap = + if (cookie.isNotEmpty()) mapOf( + "Cookie" to cookie.entries.joinToString(" ") { + "${it.key}=${it.value};" + }) else mapOf() + val tempHeaders = (DEFAULT_HEADERS + headers + cookieMap) + return tempHeaders.toHeaders() +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt new file mode 100644 index 00000000000..fc096835283 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -0,0 +1,26 @@ +package com.lagradost.cloudstream3.plugins + +import android.content.Context +import android.content.res.Resources + +/** + * The Android app's Plugin, which Android-only extensions extend. Same members as the app, except + * registerVideoClickAction: video click actions open other Android apps, which desktop cannot. + */ +abstract class Plugin : BasePlugin() { + /** + * Called when your Plugin is loaded + * @param context Context + */ + @Throws(Throwable::class) + open fun load(context: Context) { + // If not overridden by an extension then try the cross-platform load() + load() + } + + /** Extension resources are Android resources, which desktop cannot read, so this stays null */ + var resources: Resources? = null + + /** Settings screens are Android views, so desktop does not show this button */ + var openSettings: ((context: Context) -> Unit)? = null +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt new file mode 100644 index 00000000000..c2714ae541e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.plugins + +/** The Android app's record of an installed plugin */ +data class PluginData( + val internalName: String, + val url: String?, + val isOnline: Boolean, + val filePath: String, + val version: Int, +) + +/** + * The parts of the Android app's PluginManager extensions call, mostly to find their own entry. + * The desktop plugin manager fills in [installed]. + */ +object PluginManager { + @Volatile + var installed: () -> List = { emptyList() } + + /** Unloads a plugin by its file, set by the desktop plugin manager */ + @Volatile + var unloader: (String) -> Unit = {} + + fun getPluginsOnline(): Array = installed().toTypedArray() + + /** Desktop has no local plugin folder */ + fun getPluginsLocal(): Array = emptyArray() + + fun unloadPlugin(absolutePath: String) = unloader(absolutePath) +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt new file mode 100644 index 00000000000..eb76552afae --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt @@ -0,0 +1,199 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral +import kotlin.reflect.KClass +import kotlin.reflect.KProperty + +// The Android app's DataStore, which extensions use to keep their settings. Copied from the app, +// with the androidx edit {} helper written out + +const val PREFERENCES_NAME = "rebuild_preference" + +class PreferenceDelegate( + val key: String, val default: T +) { + private val klass: KClass = default::class + + // simple cache to make it not get the key every time it is accessed, however this requires + // that ONLY this changes the key + private var cache: T? = null + + operator fun getValue(self: Any?, property: KProperty<*>) = + cache ?: getKeyClass(key, klass.java).also { newCache -> cache = newCache } ?: default + + operator fun setValue( + self: Any?, + property: KProperty<*>, + t: T? + ) { + cache = t + if (t == null) { + removeKey(key) + } else { + setKeyClass(key, t) + } + } +} + +/** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ +data class Editor( + val editor: SharedPreferences.Editor +) { + /** Always remember to call apply after */ + fun setKeyRaw(path: String, value: T) { + @Suppress("UNCHECKED_CAST") + if (isStringSet(value)) { + editor.putStringSet(path, (value as Set).toMutableSet()) + } else { + when (value) { + is Boolean -> editor.putBoolean(path, value) + is Int -> editor.putInt(path, value) + is String -> editor.putString(path, value) + is Float -> editor.putFloat(path, value) + is Long -> editor.putLong(path, value) + } + } + } + + private fun isStringSet(value: Any?): Boolean { + if (value is Set<*>) { + return value.filterIsInstance().size == value.size + } + return false + } + + fun apply() { + editor.apply() + } +} + +object DataStore { + val mapper = com.lagradost.cloudstream3.mapper + + private fun getPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + } + + fun Context.getSharedPrefs(): SharedPreferences { + return getPreferences(this) + } + + fun getFolderName(folder: String, path: String): String { + return "${folder}/${path}" + } + + fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { + val editor: SharedPreferences.Editor = + if (isEditingAppSettings) context.getDefaultSharedPrefs() + .edit() else context.getSharedPrefs().edit() + return Editor(editor) + } + + fun Context.getDefaultSharedPrefs(): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(this) + } + + fun Context.getKeys(folder: String): List { + // Ensure that the folder ends with "/" to prevent matching with other folders + val fixedFolder = folder.trimEnd('/') + "/" + return this.getSharedPrefs().getAll().keys.filter { it.startsWith(fixedFolder) } + } + + fun Context.removeKey(folder: String, path: String) { + removeKey(getFolderName(folder, path)) + } + + fun Context.containsKey(folder: String, path: String): Boolean { + return containsKey(getFolderName(folder, path)) + } + + fun Context.containsKey(path: String): Boolean { + val prefs = getSharedPrefs() + return prefs.contains(path) + } + + fun Context.removeKey(path: String) { + try { + val prefs = getSharedPrefs() + if (prefs.contains(path)) { + prefs.edit().remove(path).apply() + } + } catch (e: Exception) { + logError(e) + } + } + + fun Context.removeKeys(folder: String): Int { + val keys = getKeys("$folder/") + try { + val editor = getSharedPrefs().edit() + keys.forEach { value -> + editor.remove(value) + } + editor.apply() + return keys.size + } catch (e: Exception) { + logError(e) + return 0 + } + } + + fun Context.setKey(path: String, value: T) { + try { + getSharedPrefs().edit().putString(path, value?.toJsonLiteral()).apply() + } catch (e: Exception) { + logError(e) + } + } + + fun Context.getKey(path: String, valueType: Class): T? { + return try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + parseJson(json, valueType.kotlin) + } catch (_: Exception) { + null + } + } + + fun Context.setKey(folder: String, path: String, value: T) { + setKey(getFolderName(folder, path), value) + } + + inline fun String.toKotlinObject(): T { + return parseJson(this) + } + + fun String.toKotlinObject(valueType: Class): T { + return parseJson(this, valueType.kotlin) + } + + // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR + inline fun Context.getKey(path: String, defVal: T?): T? { + return try { + val json: String = getSharedPrefs().getString(path, null) ?: return defVal + parseJson(json) + } catch (_: Exception) { + null + } + } + + inline fun Context.getKey(path: String): T? { + return getKey(path, null) + } + + inline fun Context.getKey(folder: String, path: String): T? { + return getKey(getFolderName(folder, path), null) + } + + inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { + return getKey(getFolderName(folder, path), defVal) ?: defVal + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt new file mode 100644 index 00000000000..cb16f920be6 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Event.kt @@ -0,0 +1,52 @@ +package com.lagradost.cloudstream3.utils + +/** Copied from the Android app, extensions subscribe to MainActivity's events with it */ +class Event { + private val observers = mutableSetOf<(T) -> Unit>() + + val size: Int get() = observers.size + + operator fun plusAssign(observer: (T) -> Unit) { + synchronized(observers) { + observers.add(observer) + } + } + + operator fun minusAssign(observer: (T) -> Unit) { + synchronized(observers) { + observers.remove(observer) + } + } + + operator fun invoke(value: T) { + synchronized(observers) { + for (observer in observers) + observer(value) + } + } +} + +class EmptyEvent { + private val observers = mutableSetOf() + + val size: Int get() = observers.size + + operator fun plusAssign(observer: Runnable) { + synchronized(observers) { + observers.add(observer) + } + } + + operator fun minusAssign(observer: Runnable) { + synchronized(observers) { + observers.remove(observer) + } + } + + operator fun invoke() { + synchronized(observers) { + for (observer in observers) + observer.run() + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt new file mode 100644 index 00000000000..786cf294cc5 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt @@ -0,0 +1,67 @@ +package com.lagradost.cloudstream4.android + +import java.net.URLClassLoader +import java.nio.file.Path +import java.security.CodeSource +import java.security.cert.Certificate +import java.util.jar.JarFile + +/** + * Loads an extension jar. When the extension refers to an Android class desktop has no version of, + * such as a widget for its settings screen, an empty stub of that class is generated instead, with + * the members the extension uses. The JVM only resolves classes when code using them runs, so the + * parts of the extension that search and load links work, and stubbed parts do nothing. + * + * Calls to members a desktop version of an Android class lacks are redirected as the extension's + * classes load, see MissingMembers. + */ +class AndroidPluginClassLoader( + private val jar: Path, + parent: ClassLoader, +) : URLClassLoader(arrayOf(jar.toUri().toURL()), parent) { + /** Read from the jar the first time a class loads */ + private val model by lazy { StubModel.scan(jar) } + private val generator by lazy { StubGenerator(model, parent) } + private val missingMembers by lazy { MissingMembers(model, generator, parent) } + private val codeSource = CodeSource(jar.toUri().toURL(), null as Array?) + + /** Classes are read from this rather than through URLs, whose cached jar files would keep the jar locked on Windows */ + private val jarFile = JarFile(jar.toFile()) + + private val stubbed = mutableSetOf() + + /** Names of the classes generated as stubs so far, to tell the user what the extension is missing */ + val stubbedClasses: Set get() = synchronized(stubbed) { stubbed.toSet() } + + override fun findClass(name: String): Class<*> { + missingMembers.helperClass(name)?.let { return define(name, it) } + + val internalName = name.replace('.', '/') + val entry = synchronized(jarFile) { jarFile.getJarEntry("$internalName.class") } + if (entry != null) { + val bytes = synchronized(jarFile) { jarFile.getInputStream(entry).use { it.readBytes() } } + return define(name, missingMembers.rewrite(name, bytes)) + } + + if (!StubHierarchy.isStubbed(internalName)) throw ClassNotFoundException(name) + val bytes = generator.generate(internalName) + synchronized(stubbed) { stubbed += name } + return define(name, bytes) + } + + override fun close() { + try { + super.close() + } finally { + synchronized(jarFile) { jarFile.close() } + } + } + + private fun define(name: String, bytes: ByteArray): Class<*> { + val pkg = name.substringBeforeLast('.', "") + if (pkg.isNotEmpty() && getDefinedPackage(pkg) == null) { + runCatching { definePackage(pkg, null, null, null, null, null, null, null) } + } + return defineClass(name, bytes, 0, bytes.size, codeSource) + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt new file mode 100644 index 00000000000..cea3c3a18e9 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt @@ -0,0 +1,58 @@ +package com.lagradost.cloudstream4.android + +import android.app.Activity +import android.app.Application +import android.content.SharedPreferences +import java.awt.Desktop +import java.net.URI +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.createDirectories + +/** + * The desktop side of the Android stand-ins: where extensions keep their files and settings, the + * one application and activity they see, and how their toasts reach the app. + */ +object DesktopAndroid { + const val PACKAGE_NAME = "com.lagradost.cloudstream3" + + @Volatile + private var root: Path = Path.of(System.getProperty("java.io.tmpdir"), "cloudstream-android") + + /** Sets the folder extensions keep their files and settings in. Call before loading extensions */ + fun init(dataDir: Path) { + root = dataDir + } + + val filesDir: Path get() = root.resolve("files").also { it.createDirectories() } + val cacheDir: Path get() = root.resolve("cache").also { it.createDirectories() } + private val prefsDir: Path get() = root.resolve("shared_prefs").also { it.createDirectories() } + + private val preferences = ConcurrentHashMap() + + /** The same instance for the same name, like Android, so listeners and unsaved edits are shared */ + fun sharedPreferences(name: String): SharedPreferences = + preferences.computeIfAbsent(name) { DesktopSharedPreferences(prefsDir.resolve("${safeFileName(it)}.json")) } + + val application: Application by lazy { Application() } + val activity: Activity by lazy { Activity() } + + /** Shows an extension's toast in the app. Without one, toasts are only logged */ + @Volatile + var toastHandler: ((String) -> Unit)? = null + + fun showToast(message: String) { + println("INFO Extension toast: $message") + toastHandler?.invoke(message) + } + + /** Opens a web link the way Android's ACTION_VIEW would. Returns false when nothing could open it */ + fun openUrl(url: String): Boolean = runCatching { + if (!url.startsWith("http://") && !url.startsWith("https://")) return false + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) return false + Desktop.getDesktop().browse(URI(url)) + true + }.getOrDefault(false) + + private fun safeFileName(name: String) = name.map { if (it.isLetterOrDigit() || it in "._-") it else '_' }.joinToString("") +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopSharedPreferences.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopSharedPreferences.kt new file mode 100644 index 00000000000..0ed207b71bd --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopSharedPreferences.kt @@ -0,0 +1,153 @@ +package com.lagradost.cloudstream4.android + +import android.content.SharedPreferences +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * SharedPreferences kept in a JSON file. Each value is stored with its type, because Android's + * getters throw ClassCastException for the wrong type and extensions rely on Int staying Int. + */ +internal class DesktopSharedPreferences(private val file: Path) : SharedPreferences { + private val values: MutableMap = read() + private val listeners = CopyOnWriteArrayList() + + override fun getAll(): Map = synchronized(values) { values.toMap() } + + override fun getString(key: String?, defValue: String?): String? = get(key) as String? ?: defValue + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + (get(key) as Set?)?.toMutableSet() ?: defValues + + override fun getInt(key: String?, defValue: Int): Int = get(key) as Int? ?: defValue + override fun getLong(key: String?, defValue: Long): Long = get(key) as Long? ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = get(key) as Float? ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = get(key) as Boolean? ?: defValue + override fun contains(key: String?): Boolean = synchronized(values) { values.containsKey(key) } + + private fun get(key: String?): Any? = synchronized(values) { values[key] } + + override fun edit(): SharedPreferences.Editor = EditorImpl() + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listener?.let(listeners::addIfAbsent) + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + listeners.remove(listener) + } + + private inner class EditorImpl : SharedPreferences.Editor { + private val changes = mutableMapOf() + private var clear = false + + private fun put(key: String?, value: Any?) = apply { if (key != null) synchronized(changes) { changes[key] = value } } + + override fun putString(key: String?, value: String?) = put(key, value) + override fun putStringSet(key: String?, values: MutableSet?) = put(key, values?.toSet()) + override fun putInt(key: String?, value: Int) = put(key, value) + override fun putLong(key: String?, value: Long) = put(key, value) + override fun putFloat(key: String?, value: Float) = put(key, value) + override fun putBoolean(key: String?, value: Boolean) = put(key, value) + override fun remove(key: String?) = put(key, null) + override fun clear() = apply { clear = true } + + override fun commit(): Boolean { + val changed = applyToMemory() + return runCatching { write() }.onFailure { println("ERROR SharedPreferences: Could not save $file: $it") }.isSuccess + .also { notify(changed) } + } + + override fun apply() { + val changed = applyToMemory() + writer.execute { runCatching { write() }.onFailure { println("ERROR SharedPreferences: Could not save $file: $it") } } + notify(changed) + } + + private fun applyToMemory(): List = synchronized(values) { + val changed = mutableListOf() + if (clear) { + changed += values.keys + values.clear() + } + synchronized(changes) { + for ((key, value) in changes) { + if (value == null) values.remove(key) else values[key] = value + changed += key + } + } + changed.distinct() + } + + private fun notify(changed: List) { + for (key in changed) for (listener in listeners) { + runCatching { listener.onSharedPreferenceChanged(this@DesktopSharedPreferences, key) } + } + } + } + + private fun write() = synchronized(file) { + val snapshot = synchronized(values) { values.toMap() } + val json = JsonObject(snapshot.mapValues { (_, value) -> encode(value) }) + val tmp = file.resolveSibling(file.fileName.toString() + ".tmp") + tmp.writeText(json.toString()) + Files.move(tmp, file, REPLACE_EXISTING, ATOMIC_MOVE) + } + + private fun read(): MutableMap { + val map = mutableMapOf() + if (!file.exists()) return map + runCatching { + for ((key, element) in Json.parseToJsonElement(file.readText()).jsonObject) { + decode(element.jsonObject)?.let { map[key] = it } + } + }.onFailure { println("ERROR SharedPreferences: Could not read $file: $it") } + return map + } + + private companion object { + /** All files are written from one thread, in order, like Android's apply() */ + val writer = Executors.newSingleThreadExecutor { Thread(it, "SharedPreferences writer").apply { isDaemon = true } } + + fun encode(value: Any): JsonObject = when (value) { + is String -> typed("string", JsonPrimitive(value)) + is Int -> typed("int", JsonPrimitive(value)) + is Long -> typed("long", JsonPrimitive(value)) + is Float -> typed("float", JsonPrimitive(value)) + is Boolean -> typed("boolean", JsonPrimitive(value)) + is Set<*> -> typed("set", JsonArray(value.map { JsonPrimitive(it as String) })) + else -> typed("string", JsonPrimitive(value.toString())) + } + + fun typed(type: String, value: kotlinx.serialization.json.JsonElement) = + JsonObject(mapOf("type" to JsonPrimitive(type), "value" to value)) + + fun decode(obj: JsonObject): Any? { + val value = obj["value"] ?: return null + return when (obj["type"]?.jsonPrimitive?.content) { + "string" -> value.jsonPrimitive.content + "int" -> value.jsonPrimitive.content.toInt() + "long" -> value.jsonPrimitive.content.toLong() + "float" -> value.jsonPrimitive.content.toFloat() + "boolean" -> value.jsonPrimitive.content.toBoolean() + "set" -> value.jsonArray.map { it.jsonPrimitive.content }.toSet() + else -> null + } + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt new file mode 100644 index 00000000000..1d055578bc4 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt @@ -0,0 +1,144 @@ +package com.lagradost.cloudstream4.android + +import com.googlecode.d2j.Method +import com.googlecode.d2j.dex.Dex2jar +import com.googlecode.d2j.dex.DexExceptionHandler +import com.googlecode.d2j.node.DexCodeNode +import com.googlecode.d2j.node.DexFileNode +import com.googlecode.d2j.node.DexMethodNode +import com.googlecode.d2j.reader.BaseDexFileReader +import com.googlecode.d2j.reader.DexFileReader +import com.googlecode.d2j.reader.MultiDexFileReader +import com.googlecode.d2j.reader.Op +import com.googlecode.d2j.visitors.DexFileVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import java.lang.reflect.Modifier +import java.net.URI +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipFile +import kotlin.io.path.deleteIfExists + +/** The result of converting an Android extension */ +data class ConversionResult( + /** Methods that could not be converted. They throw when called, the rest of the extension still works */ + val failedMethods: Int, +) + +/** + * Converts an Android extension (.cs3, a zip with classes.dex and manifest.json) into a jar the + * desktop app can load, with dex2jar. The manifest is copied over, so the plugin class is found + * the same way as in a desktop jar. + */ +object DexConverter { + private const val MANIFEST = "manifest.json" + + /** What dex2jar reads with the options below: no debug info, names kept as they are */ + private const val READ_FLAGS = DexFileReader.SKIP_DEBUG or DexFileReader.DONT_SANITIZE_NAMES or DexFileReader.IGNORE_READ_EXCEPTION + + /** + * Methods this big are not converted. dex2jar's memory use grows steeply with a method's size and + * try blocks: StreamPlay's 26 000 instruction invokeMovieBox ran it out of 3 GB, while its next + * biggest method, of 6 000, needs 250 MB. A method this big would also pass the JVM's 64 KB limit. + */ + private const val MAX_INSTRUCTIONS = 12_000 + private const val MAX_COST = 3_000_000L + + fun convert(cs3: Path, jar: Path): ConversionResult { + val failed = AtomicInteger() + val handler = object : DexExceptionHandler { + override fun handleFileException(e: Exception) { + throw IllegalArgumentException("Not a readable Android extension: ${e.message ?: e}", e) + } + + /** Like the dex2jar command, the method gets a body that throws instead */ + override fun handleMethodTranslateException(method: Method, node: DexMethodNode, mv: MethodVisitor, e: Exception) { + failed.incrementAndGet() + println("WARNING DexConverter: Could not convert $method in $cs3: $e") + mv.visitTypeInsn(Opcodes.NEW, "java/lang/RuntimeException") + mv.visitInsn(Opcodes.DUP) + mv.visitLdcInsn("This method of the extension could not be converted for desktop: $e") + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/RuntimeException", "", "(Ljava/lang/String;)V", false) + mv.visitInsn(Opcodes.ATHROW) + } + } + + val dex = DexFileNode() + MultiDexFileReader.open(Files.readAllBytes(cs3)).accept(dex, READ_FLAGS) + failed.addAndGet(replaceHugeMethods(dex, cs3)) + + val tmp = jar.resolveSibling("${jar.fileName}.tmp") + tmp.deleteIfExists() + try { + // The same options as the dex2jar command's defaults, except that names are kept: + // Kotlin names such as "constructor-impl" are valid on the JVM, and renaming them breaks the calls + Dex2jar.from(NodeReader(dex)) + .withExceptionHandler(handler) + .reUseReg(false) + .topoLogicalSort() + .skipDebug(true) + .dontSanitizeNames(true) + .optimizeSynchronized(false) + .printIR(false) + .noCode(false) + .skipExceptions(false) + .to(tmp) + copyManifest(cs3, tmp) + Files.move(tmp, jar, REPLACE_EXISTING, ATOMIC_MOVE) + } finally { + tmp.deleteIfExists() + } + return ConversionResult(failed.get()) + } + + /** Gives methods too big to convert a body that throws. Returns how many there were */ + private fun replaceHugeMethods(dex: DexFileNode, cs3: Path): Int { + var replaced = 0 + for (cls in dex.clzs.orEmpty()) for (method in cls.methods.orEmpty()) { + val code = method.codeNode ?: continue + val instructions = code.stmts.size + val cost = instructions.toLong() * (code.totalRegister + (code.tryStmts?.size ?: 0)) + if (instructions <= MAX_INSTRUCTIONS && cost <= MAX_COST) continue + println("WARNING DexConverter: ${method.method} in $cs3 is too big to convert ($instructions instructions)") + method.codeNode = throwingCode(method) + replaced++ + } + return replaced + } + + /** throw new RuntimeException(message), with the method's parameters in the last registers like dex expects */ + private fun throwingCode(method: DexMethodNode): DexCodeNode { + val parameterRegisters = method.method.parameterTypes.sumOf { if (it == "J" || it == "D") 2 else 1 } + + if (method.access and Modifier.STATIC != 0) 0 else 1 + return DexCodeNode().apply { + visitRegister(2 + parameterRegisters) + visitTypeStmt(Op.NEW_INSTANCE, 0, 0, "Ljava/lang/RuntimeException;") + visitConstStmt(Op.CONST_STRING, 1, "This method of the extension is too big to convert for desktop") + visitMethodStmt(Op.INVOKE_DIRECT, intArrayOf(0, 1), Method("Ljava/lang/RuntimeException;", "", arrayOf("Ljava/lang/String;"), "V")) + visitStmt1R(Op.THROW, 0) + } + } + + /** Hands dex2jar the dex that was read and edited above */ + private class NodeReader(private val dex: DexFileNode) : BaseDexFileReader { + override fun getDexVersion(): Int = dex.dexVersion + override fun getClassNames(): List = dex.clzs.map { it.className } + override fun accept(dv: DexFileVisitor) = dex.accept(dv) + override fun accept(dv: DexFileVisitor, config: Int) = dex.accept(dv) + override fun accept(dv: DexFileVisitor, classIdx: Int, config: Int) = dex.clzs[classIdx].accept(dv) + } + + private fun copyManifest(cs3: Path, jar: Path) { + val manifest = ZipFile(cs3.toFile()).use { zip -> + zip.getEntry(MANIFEST)?.let { entry -> zip.getInputStream(entry).use { it.readBytes() } } + } ?: return + FileSystems.newFileSystem(URI.create("jar:" + jar.toUri()), emptyMap()).use { fs -> + Files.write(fs.getPath(MANIFEST), manifest) + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/MissingMembers.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/MissingMembers.kt new file mode 100644 index 00000000000..459c3f239b6 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/MissingMembers.kt @@ -0,0 +1,131 @@ +package com.lagradost.cloudstream4.android + +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type + +/** + * The desktop versions of Android classes, such as Context, only have the members extensions + * commonly use. When an extension class uses one they lack, such as Context.getContentResolver(), + * the call is redirected while the class loads to a generated helper that returns a default value, + * the same thing a stub does. Without this the call would fail with NoSuchMethodError. + * + * Only the desktop versions in this module are treated this way, never the library or the JDK, + * where a missing member is a real error. + */ +internal class MissingMembers( + private val model: StubModel, + private val generator: StubGenerator, + private val parent: ClassLoader, +) { + private val standInLocation = MissingMembers::class.java.protectionDomain?.codeSource?.location + + private class Helper(val name: String, val desc: String) + + /** Helper classes waiting to be defined, by binary class name */ + private val helpers = HashMap>() + + /** The desktop version a member is missing from, or null when it exists or is not ours to fill in */ + private val missing = HashMap?>() + + private fun parentClass(internalName: String): Class<*>? = try { + Class.forName(internalName.replace('/', '.'), false, parent) + } catch (_: ClassNotFoundException) { + null + } catch (_: LinkageError) { + null + } + + private fun isStandIn(cls: Class<*>) = StubHierarchy.isStubbed(Type.getInternalName(cls)) && + standInLocation != null && cls.protectionDomain?.codeSource?.location == standInLocation + + private fun missingFrom(owner: String, name: String, desc: String, isField: Boolean): Class<*>? = + synchronized(missing) { + missing.getOrPut("$owner.$name$desc$isField") { + if (owner.startsWith("[")) return@getOrPut null + // Members used through an extension class come from its first ancestor outside the extension + val ancestor = model.firstAncestorOutside(owner, name + desc) ?: return@getOrPut null + if (name == "" && ancestor != owner) return@getOrPut null + val cls = parentClass(ancestor)?.takeIf(::isStandIn) ?: return@getOrPut null + val exists = when { + isField -> generator.hasField(cls, name, desc) + name == "" -> cls.declaredConstructors.any { Type.getConstructorDescriptor(it) == desc } + else -> generator.hasMethod(cls, name, desc) + } + if (exists) null else cls + } + } + + /** Returns the class with calls to missing members redirected, or the same bytes when there are none */ + fun rewrite(binaryName: String, bytes: ByteArray): ByteArray { + val reader = ClassReader(bytes) + val writer = ClassWriter(reader, 0) + val helperName = binaryName.replace('.', '/') + "\$\$DesktopMissing" + val added = mutableListOf() + + fun helper(desc: String): Helper = Helper("missing${added.size}", desc).also { added += it } + + reader.accept(object : ClassVisitor(Opcodes.ASM9, writer) { + override fun visitMethod(access: Int, name: String, descriptor: String, signature: String?, exceptions: Array?): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)) { + override fun visitMethodInsn(opcode: Int, owner: String, name: String, desc: String, isInterface: Boolean) { + val cls = missingFrom(owner, name, desc, isField = false) + when { + cls == null -> super.visitMethodInsn(opcode, owner, name, desc, isInterface) + name == "" -> { + // The arguments are used up by the helper and the object made with the constructor that exists + if (cls.declaredConstructors.none { it.parameterCount == 0 }) { + super.visitMethodInsn(opcode, owner, name, desc, isInterface) + return + } + val h = helper(desc) + super.visitMethodInsn(Opcodes.INVOKESTATIC, helperName, h.name, h.desc, false) + super.visitMethodInsn(Opcodes.INVOKESPECIAL, owner, "", "()V", false) + } + else -> { + val h = helper(if (opcode == Opcodes.INVOKESTATIC) desc else "(L$owner;" + desc.substring(1)) + super.visitMethodInsn(Opcodes.INVOKESTATIC, helperName, h.name, h.desc, false) + } + } + } + + override fun visitFieldInsn(opcode: Int, owner: String, name: String, desc: String) { + if (missingFrom(owner, name, desc, isField = true) == null) return super.visitFieldInsn(opcode, owner, name, desc) + val h = helper( + when (opcode) { + Opcodes.GETSTATIC -> "()$desc" + Opcodes.GETFIELD -> "(L$owner;)$desc" + Opcodes.PUTSTATIC -> "($desc)V" + else -> "(L$owner;$desc)V" + } + ) + super.visitMethodInsn(Opcodes.INVOKESTATIC, helperName, h.name, h.desc, false) + } + } + }, 0) + + if (added.isEmpty()) return bytes + synchronized(helpers) { helpers[helperName.replace('/', '.')] = added } + return writer.toByteArray() + } + + /** The helper class for an extension class rewritten above, or null when the name is not one */ + fun helperClass(binaryName: String): ByteArray? { + val list = synchronized(helpers) { helpers.remove(binaryName) } ?: return null + val writer = ClassWriter(ClassWriter.COMPUTE_MAXS) + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC or Opcodes.ACC_FINAL or Opcodes.ACC_SUPER, binaryName.replace('.', '/'), null, "java/lang/Object", null) + for (helper in list) { + writer.visitMethod(Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC, helper.name, helper.desc, null, null).apply { + visitCode() + generator.returnDefault(this, Type.getReturnType(helper.desc)) + visitMaxs(0, 0) + visitEnd() + } + } + writer.visitEnd() + return writer.toByteArray() + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubGenerator.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubGenerator.kt new file mode 100644 index 00000000000..cc33518f5dd --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubGenerator.kt @@ -0,0 +1,342 @@ +package com.lagradost.cloudstream4.android + +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import java.nio.file.Path +import java.util.jar.JarFile + +/** What an extension uses of each class desktop has to generate a stub for */ +internal class StubModel private constructor() { + class Member(val name: String, val desc: String, val isStatic: Boolean) + + class TypeUse { + /** Called with invokeinterface or implemented by an extension class */ + var usedAsInterface = false + + /** Created with new or extended by an extension class */ + var usedAsClass = false + val methods = LinkedHashMap() + val fields = LinkedHashMap() + + val isInterface get() = usedAsInterface && !usedAsClass + } + + private class ExtensionClass(val superName: String?, val declared: MutableSet = mutableSetOf()) + + /** A use of a member through an extension class, which may come from a stubbed superclass */ + private class InheritedUse(val owner: String, val member: Member, val isField: Boolean) + + private val extensionClasses = HashMap() + private val inheritedUses = mutableListOf() + val types = HashMap() + + private fun use(name: String) = types.getOrPut(name) { TypeUse() } + + private fun record(owner: String, member: Member, isField: Boolean) { + when { + owner.startsWith("[") -> {} + StubHierarchy.isStubbed(owner) -> { + val use = use(owner) + (if (isField) use.fields else use.methods).putIfAbsent(member.name + member.desc, member) + } + else -> inheritedUses += InheritedUse(owner, member, isField) + } + } + + private fun scanClass(bytes: ByteArray) { + ClassReader(bytes).accept(object : ClassVisitor(Opcodes.ASM9) { + lateinit var current: ExtensionClass + + override fun visit(version: Int, access: Int, name: String, signature: String?, superName: String?, interfaces: Array?) { + current = ExtensionClass(superName).also { extensionClasses[name] = it } + if (superName != null && StubHierarchy.isStubbed(superName)) use(superName).usedAsClass = true + interfaces?.filter(StubHierarchy::isStubbed)?.forEach { use(it).usedAsInterface = true } + } + + override fun visitField(access: Int, name: String, descriptor: String, signature: String?, value: Any?) = + null.also { current.declared += name + descriptor } + + override fun visitMethod(access: Int, name: String, descriptor: String, signature: String?, exceptions: Array?): MethodVisitor { + current.declared += name + descriptor + return object : MethodVisitor(Opcodes.ASM9) { + override fun visitMethodInsn(opcode: Int, owner: String, name: String, descriptor: String, isInterface: Boolean) { + if (isInterface && StubHierarchy.isStubbed(owner)) use(owner).usedAsInterface = true + record(owner, Member(name, descriptor, opcode == Opcodes.INVOKESTATIC), isField = false) + } + + override fun visitFieldInsn(opcode: Int, owner: String, name: String, descriptor: String) { + val isStatic = opcode == Opcodes.GETSTATIC || opcode == Opcodes.PUTSTATIC + record(owner, Member(name, descriptor, isStatic), isField = true) + } + + override fun visitTypeInsn(opcode: Int, type: String) { + if (opcode == Opcodes.NEW && StubHierarchy.isStubbed(type)) use(type).usedAsClass = true + } + } + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + + /** + * Where a member used through [owner] comes from: [owner] itself when it is not an extension + * class, else its first ancestor outside the extension. Null when the extension declares it. + */ + fun firstAncestorOutside(owner: String, key: String): String? { + var current: String? = owner + while (current != null) { + val cls = extensionClasses[current] ?: return current + if (key in cls.declared) return null + current = cls.superName + } + return null + } + + /** Members used through an extension class but declared nowhere in the extension belong to its stubbed ancestor */ + private fun resolveInheritedUses() { + for (use in inheritedUses) { + val key = use.member.name + use.member.desc + var current: String? = use.owner + while (current != null) { + val cls = extensionClasses[current] ?: break + if (key in cls.declared) break + current = cls.superName + } + if (current != null && extensionClasses[current] == null && StubHierarchy.isStubbed(current)) { + val type = use(current) + (if (use.isField) type.fields else type.methods).putIfAbsent(key, use.member) + } + } + inheritedUses.clear() + } + + companion object { + fun scan(jar: Path): StubModel = StubModel().apply { + JarFile(jar.toFile()).use { file -> + for (entry in file.entries()) { + if (!entry.name.endsWith(".class")) continue + runCatching { scanClass(file.getInputStream(entry).use { it.readBytes() }) } + .onFailure { println("WARNING StubModel: Could not read ${entry.name} in $jar: $it") } + } + } + resolveInheritedUses() + } + } +} + +/** + * Writes the stub classes. A stub has the members the extension uses: constructors that do nothing, + * and methods that return a default value. Where the value is an object the stub can create, such + * as Toast.makeText's Toast, it returns a new one so chained calls keep doing nothing rather than + * failing on null. Members a real desktop class above the stub already has are left out, so the + * stub never hides them. + */ +internal class StubGenerator(private val model: StubModel, private val parent: ClassLoader) { + private val parentClasses = HashMap?>() + + private fun parentClass(internalName: String): Class<*>? = parentClasses.getOrPut(internalName) { + try { + Class.forName(internalName.replace('/', '.'), false, parent) + } catch (_: ClassNotFoundException) { + null + } catch (_: LinkageError) { + null + } + } + + private fun isInterface(internalName: String): Boolean = + parentClass(internalName)?.isInterface ?: (model.types[internalName]?.isInterface == true) + + /** The first real class above a stub, where the stub's own ancestors end */ + private fun firstRealAncestor(superName: String): Class<*>? { + var current = superName + repeat(32) { + parentClass(current)?.let { return it } + if (!StubHierarchy.isStubbed(current)) return null + current = StubHierarchy.superclassOf(current) + } + return null + } + + fun hasMethod(cls: Class<*>, name: String, desc: String): Boolean { + val seen = HashSet>() + val queue = ArrayDeque(listOf(cls)) + while (queue.isNotEmpty()) { + val c = queue.removeFirst() + if (!seen.add(c)) continue + if (c.declaredMethods.any { it.name == name && Type.getMethodDescriptor(it) == desc }) return true + c.superclass?.let(queue::add) + queue.addAll(c.interfaces) + } + return false + } + + fun hasField(cls: Class<*>, name: String, desc: String): Boolean { + var c: Class<*>? = cls + while (c != null) { + if (c.declaredFields.any { it.name == name && Type.getDescriptor(it.type) == desc }) return true + if (c.interfaces.any { i -> i.fields.any { it.name == name } }) return true + c = c.superclass + } + return false + } + + fun generate(internalName: String): ByteArray { + val use = model.types[internalName] ?: StubModel.TypeUse() + val isInterface = use.isInterface + val superName = if (isInterface) "java/lang/Object" else StubHierarchy.superclassOf(internalName) + val realAncestor = firstRealAncestor(superName) ?: Any::class.java + + val writer = ClassWriter(ClassWriter.COMPUTE_MAXS) + val access = Opcodes.ACC_PUBLIC or if (isInterface) Opcodes.ACC_INTERFACE or Opcodes.ACC_ABSTRACT else Opcodes.ACC_SUPER + writer.visit(Opcodes.V1_8, access, internalName, null, superName, null) + + // Static object fields get an instance when the stub can make one, such as a Kotlin Companion or INSTANCE + val initialized = mutableListOf() + for (field in use.fields.values) { + if (hasField(realAncestor, field.name, field.desc)) continue + val isStatic = isInterface || field.isStatic + val fieldAccess = Opcodes.ACC_PUBLIC or when { + isInterface -> Opcodes.ACC_STATIC or Opcodes.ACC_FINAL + field.isStatic -> Opcodes.ACC_STATIC + else -> 0 + } + writer.visitField(fieldAccess, field.name, field.desc, null, null).visitEnd() + val type = Type.getType(field.desc) + if (isStatic && type.sort == Type.OBJECT && canCreate(type.internalName)) initialized += field + } + if (initialized.isNotEmpty()) { + writer.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null).apply { + visitCode() + for (field in initialized) { + newInstance(this, Type.getType(field.desc).internalName) + visitFieldInsn(Opcodes.PUTSTATIC, internalName, field.name, field.desc) + } + visitInsn(Opcodes.RETURN) + visitMaxs(0, 0) + visitEnd() + } + } + + if (!isInterface) { + val constructors = use.methods.values.filter { it.name == "" }.map { it.desc }.toMutableSet() + constructors += "()V" + for (desc in constructors) { + writer.visitMethod(Opcodes.ACC_PUBLIC, "", desc, null, null).apply { + visitCode() + visitVarInsn(Opcodes.ALOAD, 0) + visitMethodInsn(Opcodes.INVOKESPECIAL, superName, "", "()V", false) + visitInsn(Opcodes.RETURN) + visitMaxs(0, 0) + visitEnd() + } + } + } + + val written = HashSet() + for (method in use.methods.values) { + if (method.name.startsWith("<")) continue + // A static and an instance method cannot share a name and descriptor, keep the first use + if (!written.add(method.name + method.desc)) continue + if (!method.isStatic && hasMethod(if (isInterface) Any::class.java else realAncestor, method.name, method.desc)) continue + if (method.isStatic && !isInterface && hasMethod(realAncestor, method.name, method.desc)) continue + val methodAccess = Opcodes.ACC_PUBLIC or if (method.isStatic) Opcodes.ACC_STATIC else 0 + writer.visitMethod(methodAccess, method.name, method.desc, null, null).apply { + visitCode() + returnDefault(this, Type.getReturnType(method.desc)) + visitMaxs(0, 0) + visitEnd() + } + } + + writer.visitEnd() + return writer.toByteArray() + } + + /** True when a stub can return a new instance of the type */ + private fun canCreate(internalName: String): Boolean { + val real = parentClass(internalName) + if (real != null) { + val modifiers = real.modifiers + return !real.isInterface && !java.lang.reflect.Modifier.isAbstract(modifiers) && + java.lang.reflect.Modifier.isPublic(modifiers) && + real.constructors.any { it.parameterCount == 0 } && + StubHierarchy.isStubbed(internalName) + } + return StubHierarchy.isStubbed(internalName) && !isInterface(internalName) + } + + fun returnDefault(mv: MethodVisitor, type: Type) { + when (type.sort) { + Type.VOID -> mv.visitInsn(Opcodes.RETURN) + Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> { + mv.visitInsn(Opcodes.ICONST_0) + mv.visitInsn(Opcodes.IRETURN) + } + Type.LONG -> { + mv.visitInsn(Opcodes.LCONST_0) + mv.visitInsn(Opcodes.LRETURN) + } + Type.FLOAT -> { + mv.visitInsn(Opcodes.FCONST_0) + mv.visitInsn(Opcodes.FRETURN) + } + Type.DOUBLE -> { + mv.visitInsn(Opcodes.DCONST_0) + mv.visitInsn(Opcodes.DRETURN) + } + Type.ARRAY -> { + mv.visitInsn(Opcodes.ICONST_0) + val element = Type.getType(type.descriptor.substring(1)) + when (element.sort) { + Type.OBJECT, Type.ARRAY -> mv.visitTypeInsn(Opcodes.ANEWARRAY, element.internalName) + else -> mv.visitIntInsn(Opcodes.NEWARRAY, newArrayCode(element)) + } + mv.visitInsn(Opcodes.ARETURN) + } + else -> { + val name = type.internalName + val created = when (name) { + "java/lang/String", "java/lang/CharSequence" -> { + mv.visitLdcInsn("") + true + } + "java/util/List", "java/util/Collection", "java/lang/Iterable", "java/util/ArrayList" -> newInstance(mv, "java/util/ArrayList") + "java/util/Set", "java/util/HashSet" -> newInstance(mv, "java/util/HashSet") + "java/util/Map", "java/util/HashMap" -> newInstance(mv, "java/util/HashMap") + // Such as View.getContext(), the desktop activity is every context there is + "android/content/Context", "android/content/ContextWrapper", "android/view/ContextThemeWrapper", "android/app/Activity" -> { + val desktop = Type.getInternalName(DesktopAndroid::class.java) + mv.visitFieldInsn(Opcodes.GETSTATIC, desktop, "INSTANCE", "L$desktop;") + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, desktop, "getActivity", "()Landroid/app/Activity;", false) + true + } + else -> if (canCreate(name)) newInstance(mv, name) else false + } + if (!created) mv.visitInsn(Opcodes.ACONST_NULL) + mv.visitInsn(Opcodes.ARETURN) + } + } + } + + private fun newInstance(mv: MethodVisitor, internalName: String): Boolean { + mv.visitTypeInsn(Opcodes.NEW, internalName) + mv.visitInsn(Opcodes.DUP) + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, internalName, "", "()V", false) + return true + } + + private fun newArrayCode(element: Type) = when (element.sort) { + Type.BOOLEAN -> Opcodes.T_BOOLEAN + Type.CHAR -> Opcodes.T_CHAR + Type.BYTE -> Opcodes.T_BYTE + Type.SHORT -> Opcodes.T_SHORT + Type.INT -> Opcodes.T_INT + Type.LONG -> Opcodes.T_LONG + Type.FLOAT -> Opcodes.T_FLOAT + else -> Opcodes.T_DOUBLE + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt new file mode 100644 index 00000000000..9c30ad8810c --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt @@ -0,0 +1,134 @@ +package com.lagradost.cloudstream4.android + +/** + * Superclasses of the Android classes desktop generates stubs for. The JVM checks these when an + * extension passes, say, a TextView where a View is expected, so the stubs need the real + * hierarchy. Classes not listed extend Object, or Exception and Error going by their name. + */ +internal object StubHierarchy { + /** Namespaces whose missing classes are generated, in internal name form */ + val stubbedPrefixes = listOf( + "android/", + "androidx/", + "dalvik/", + "com/android/", + "com/google/android/", + "org/xmlpull/", + // App classes extensions use that desktop has no version of, such as the sync providers + "com/lagradost/cloudstream3/", + ) + + fun isStubbed(internalName: String) = stubbedPrefixes.any(internalName::startsWith) + + fun superclassOf(internalName: String): String { + superclasses[internalName]?.let { return it } + val simple = internalName.substringAfterLast('/').substringAfterLast('$') + return when { + simple.endsWith("Exception") -> "java/lang/RuntimeException" + simple.endsWith("Error") -> "java/lang/Error" + else -> "java/lang/Object" + } + } + + private val superclasses: Map = buildMap { + fun extends(parent: String, vararg children: String) = children.forEach { put(it, parent) } + + // Views + extends("android/view/View", "android/view/ViewGroup", "android/widget/TextView", "android/widget/ImageView", + "android/widget/ProgressBar", "android/view/SurfaceView", "android/view/TextureView", "android/widget/Space") + extends("android/view/ViewGroup", "android/widget/LinearLayout", "android/widget/FrameLayout", + "android/widget/RelativeLayout", "android/widget/AdapterView", "android/widget/AbsoluteLayout", + "android/widget/GridLayout", "androidx/recyclerview/widget/RecyclerView", + "androidx/constraintlayout/widget/ConstraintLayout", "androidx/coordinatorlayout/widget/CoordinatorLayout", + "androidx/viewpager/widget/ViewPager", "com/google/android/material/internal/FlowLayout") + extends("android/widget/AbsoluteLayout", "android/webkit/WebView") + extends("android/widget/FrameLayout", "android/widget/ScrollView", "android/widget/HorizontalScrollView", + "androidx/cardview/widget/CardView", "androidx/core/widget/NestedScrollView", + "androidx/fragment/app/FragmentContainerView", "androidx/swiperefreshlayout/widget/SwipeRefreshLayout") + extends("android/widget/LinearLayout", "android/widget/RadioGroup", "android/widget/TableLayout", + "android/widget/TableRow", "android/widget/SearchView", "androidx/appcompat/widget/LinearLayoutCompat", + "com/google/android/material/textfield/TextInputLayout") + extends("android/widget/AdapterView", "android/widget/AbsListView", "android/widget/AbsSpinner") + extends("android/widget/AbsListView", "android/widget/ListView", "android/widget/GridView") + extends("android/widget/AbsSpinner", "android/widget/Spinner") + extends("android/widget/TextView", "android/widget/Button", "android/widget/EditText", + "androidx/appcompat/widget/AppCompatTextView") + extends("android/widget/Button", "android/widget/CompoundButton", "androidx/appcompat/widget/AppCompatButton") + extends("android/widget/CompoundButton", "android/widget/CheckBox", "android/widget/Switch", + "android/widget/RadioButton", "android/widget/ToggleButton", "androidx/appcompat/widget/SwitchCompat") + extends("android/widget/EditText", "android/widget/AutoCompleteTextView", "androidx/appcompat/widget/AppCompatEditText") + extends("android/widget/ImageView", "android/widget/ImageButton", "androidx/appcompat/widget/AppCompatImageView") + extends("android/widget/ProgressBar", "android/widget/AbsSeekBar") + extends("android/widget/AbsSeekBar", "android/widget/SeekBar", "android/widget/RatingBar") + extends("androidx/appcompat/widget/AppCompatButton", "com/google/android/material/button/MaterialButton") + extends("androidx/appcompat/widget/AppCompatEditText", "com/google/android/material/textfield/TextInputEditText") + extends("android/widget/CheckBox", "androidx/appcompat/widget/AppCompatCheckBox") + extends("androidx/appcompat/widget/AppCompatCheckBox", "com/google/android/material/chip/Chip") + extends("com/google/android/material/internal/FlowLayout", "com/google/android/material/chip/ChipGroup") + extends("androidx/appcompat/widget/SwitchCompat", "com/google/android/material/materialswitch/MaterialSwitch", + "com/google/android/material/switchmaterial/SwitchMaterial") + extends("androidx/cardview/widget/CardView", "com/google/android/material/card/MaterialCardView") + + // Layout parameters + extends("android/view/ViewGroup\$LayoutParams", "android/view/ViewGroup\$MarginLayoutParams", + "android/view/WindowManager\$LayoutParams") + extends("android/view/ViewGroup\$MarginLayoutParams", "android/widget/LinearLayout\$LayoutParams", + "android/widget/FrameLayout\$LayoutParams", "android/widget/RelativeLayout\$LayoutParams", + "androidx/recyclerview/widget/RecyclerView\$LayoutParams", + "androidx/constraintlayout/widget/ConstraintLayout\$LayoutParams") + + // Drawables and animation + extends("android/graphics/drawable/Drawable", "android/graphics/drawable/GradientDrawable", + "android/graphics/drawable/ColorDrawable", "android/graphics/drawable/BitmapDrawable", + "android/graphics/drawable/ShapeDrawable", "android/graphics/drawable/LayerDrawable", + "android/graphics/drawable/DrawableContainer", "android/graphics/drawable/InsetDrawable") + extends("android/graphics/drawable/DrawableContainer", "android/graphics/drawable/StateListDrawable") + extends("android/graphics/drawable/LayerDrawable", "android/graphics/drawable/RippleDrawable") + extends("android/animation/Animator", "android/animation/ValueAnimator", "android/animation/AnimatorSet") + extends("android/animation/ValueAnimator", "android/animation/ObjectAnimator") + extends("android/text/style/CharacterStyle", "android/text/style/MetricAffectingSpan", + "android/text/style/ForegroundColorSpan", "android/text/style/BackgroundColorSpan", + "android/text/style/ClickableSpan", "android/text/style/UnderlineSpan", "android/text/style/StrikethroughSpan") + extends("android/text/style/MetricAffectingSpan", "android/text/style/StyleSpan", + "android/text/style/RelativeSizeSpan", "android/text/style/AbsoluteSizeSpan", "android/text/style/TypefaceSpan") + + // Dialogs, fragments and activities + extends("android/app/Dialog", "android/app/AlertDialog", "androidx/appcompat/app/AppCompatDialog") + extends("androidx/appcompat/app/AppCompatDialog", "androidx/appcompat/app/AlertDialog", + "com/google/android/material/bottomsheet/BottomSheetDialog") + extends("android/app/Fragment", "android/app/DialogFragment") + extends("androidx/fragment/app/Fragment", "androidx/fragment/app/DialogFragment", + "androidx/preference/PreferenceFragmentCompat") + extends("androidx/fragment/app/DialogFragment", "androidx/appcompat/app/AppCompatDialogFragment") + extends("androidx/appcompat/app/AppCompatDialogFragment", "com/google/android/material/bottomsheet/BottomSheetDialogFragment") + extends("android/app/Activity", "androidx/core/app/ComponentActivity") + extends("androidx/core/app/ComponentActivity", "androidx/activity/ComponentActivity") + extends("androidx/activity/ComponentActivity", "androidx/fragment/app/FragmentActivity") + extends("androidx/fragment/app/FragmentActivity", "androidx/appcompat/app/AppCompatActivity") + extends("android/content/ContextWrapper", "android/app/Service", "android/view/ContextThemeWrapper") + extends("android/view/ContextThemeWrapper", "androidx/appcompat/view/ContextThemeWrapper") + + // Preferences + extends("androidx/preference/Preference", "androidx/preference/TwoStatePreference", + "androidx/preference/DialogPreference", "androidx/preference/PreferenceGroup", + "androidx/preference/SeekBarPreference") + extends("androidx/preference/TwoStatePreference", "androidx/preference/SwitchPreferenceCompat", + "androidx/preference/CheckBoxPreference", "androidx/preference/SwitchPreference") + extends("androidx/preference/DialogPreference", "androidx/preference/EditTextPreference", + "androidx/preference/ListPreference", "androidx/preference/MultiSelectListPreference") + extends("androidx/preference/PreferenceGroup", "androidx/preference/PreferenceCategory", + "androidx/preference/PreferenceScreen") + + // Lists + extends("androidx/recyclerview/widget/RecyclerView\$LayoutManager", "androidx/recyclerview/widget/LinearLayoutManager") + extends("androidx/recyclerview/widget/LinearLayoutManager", "androidx/recyclerview/widget/GridLayoutManager") + extends("androidx/recyclerview/widget/RecyclerView\$Adapter", "androidx/recyclerview/widget/ListAdapter") + extends("androidx/recyclerview/widget/RecyclerView\$ItemDecoration", "androidx/recyclerview/widget/ItemTouchHelper") + extends("androidx/recyclerview/widget/ItemTouchHelper\$Callback", "androidx/recyclerview/widget/ItemTouchHelper\$SimpleCallback") + extends("android/widget/BaseAdapter", "android/widget/ArrayAdapter") + + // Lifecycle + extends("androidx/lifecycle/LiveData", "androidx/lifecycle/MutableLiveData") + extends("androidx/lifecycle/MutableLiveData", "androidx/lifecycle/MediatorLiveData") + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 13cecdacc46..1c421bdc872 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -109,7 +109,8 @@ compose.desktop { packageVersion = libs.versions.versionName.get() // The packaged runtime only contains these JDK modules, regenerate with ./gradlew :desktopApp:suggestRuntimeModules - modules("java.instrument", "java.management", "java.net.http", "java.sql", "jdk.dynalink", "jdk.unsupported") + // jdk.zipfs is used when converting Android extensions, which suggestRuntimeModules does not see + modules("java.instrument", "java.management", "java.net.http", "java.sql", "jdk.dynalink", "jdk.unsupported", "jdk.zipfs") val iconsRoot = project.file("src/desktop-icons") macOS { diff --git a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt index c135b8593ec..abc81c16d35 100644 --- a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt +++ b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt @@ -24,3 +24,15 @@ Source code: The complete corresponding source code of libmpv-2.dll is also published with each CloudStream desktop release on GitHub, next to the installer. + +Android extension support +------------------------- +Extensions built only for Android are converted for desktop with these +libraries, which are included as they are: + + dex2jar 2.4.38 Apache License 2.0 + https://github.com/ThexXTURBOXx/dex2jar + Includes code from the Android Open Source Project + (dx), Apache License 2.0 + ASM 9.10.1 BSD 3-Clause License, https://asm.ow2.io + Gson 2.14.0 Apache License 2.0, https://github.com/google/gson diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt index 77ee0cf6487..2a1675764e0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -24,7 +24,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp +import coil3.compose.rememberAsyncImagePainter +import com.lagradost.cloudstream3.plugins.Plugin +import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream4.desktopPreferences import com.lagradost.cloudstream4.generated.resources.* import com.lagradost.cloudstream4.plugins.DesktopPluginManager @@ -47,6 +51,29 @@ private val repositories = desktopPreferences.getStringSet("desktop_repositories private val client = RepositoryClient() +/** The name as the Android app shows it, which leaves out the "Provider" most names end with */ +private fun displayName(name: String) = name.removeSuffix("Provider") + +/** The extension's icon from its repository, or the extension icon when it has none or it fails to load */ +@Composable +private fun extensionIcon(iconUrl: String?): Painter { + val fallback = painterResource(Res.drawable.extension_24px) + if (iconUrl.isNullOrBlank()) return fallback + // The same sizes the Android app asks for + val url = iconUrl.replace("%size%", "64").replace("%exact_size%", "64") + return rememberAsyncImagePainter(url, placeholder = fallback, error = fallback, fallback = fallback) +} + +/** Windows draws no flag emoji, so the language is shown by name rather than with Android's flag */ +private fun languageName(code: String?): String? = + code?.takeIf { it.isNotBlank() }?.let { SubtitleHelper.fromTagToEnglishLanguageName(it) ?: it.uppercase() } + +private fun fileSize(bytes: Long?): String? = when { + bytes == null -> null + bytes < 1024 * 1024 -> "${(bytes + 1023) / 1024} KB" + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) +} + /** Adds every repository in the community list, which is what MegaRepo does on Android. Returns how many were new */ private suspend fun addCommunityRepositories(): Int { val community = client.fetchCommunityRepositories() @@ -71,7 +98,10 @@ private suspend fun addRepository(input: String): String { return "Added ${url.removePrefix("https://")}" } -/** Repositories and installed extensions. Only plugins built with `isCrossPlatform = true` can run on desktop */ +/** + * Repositories and installed extensions. Extensions built with `isCrossPlatform = true` run as they + * are, the others are converted from their Android build when they are installed. + */ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { @Composable override fun getTitleRes(): String = "Extensions" @@ -82,6 +112,7 @@ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : Searcha val repos by repositories.collectAsState() val installed by manager.installed.collectAsState() val errors by manager.errors.collectAsState() + val loadedNames by manager.loadedNames.collectAsState() val scope = rememberCoroutineScope() val busy = remember { mutableStateMapOf() } @@ -159,9 +190,21 @@ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : Searcha val installedItems = installed.sortedBy { it.name.lowercase() }.map { plugin -> val error = errors[plugin.internalName] + val loaded = if (plugin.internalName in loadedNames) manager.loadedPlugin(plugin.internalName) else null + val providers = loaded?.providers?.size ?: 0 Preference.PreferenceItem.TextPreference( - title = plugin.name, - subtitle = error?.let { "Failed to load: $it" } ?: "Version ${plugin.version}", + title = displayName(plugin.name), + icon = extensionIcon(plugin.iconUrl), + subtitle = error?.let { "Failed to load: $it" } ?: listOfNotNull( + "Version ${plugin.version}", + "Android build, converted for desktop".takeIf { plugin.isAndroid }, + when (providers) { + 0 -> null + 1 -> "1 provider" + else -> "$providers providers" + }, + "Its settings screen needs Android".takeIf { (loaded?.instance as? Plugin)?.openSettings != null }, + ).joinToString(" · "), widget = { BusyOr(busy[plugin.internalName] == true) { OutlinedButton(onClick = { @@ -231,68 +274,77 @@ class RepositoryScreen(private val url: String) : SearchableSettings { is State.Loaded -> if (current.plugins.any { it.internalName == RepositoryClient.MEGA_REPO_PLUGIN }) { listOf(megaRepoGroup(scope)) } else { - val (desktop, androidOnly) = current.plugins.sortedBy { it.name.lowercase() }.partition { it.supportsDesktop } - // Product Sans draws "(5)" as a circled digit, so counts use a separator instead of parentheses - listOfNotNull( - Preference.PreferenceGroup( - title = "Available on desktop · ${desktop.size}", - preferenceItems = if (desktop.isEmpty()) listOf( - Preference.PreferenceItem.InfoPreference( - "Nothing in this repository runs on desktop yet. All ${androidOnly.size} of its " + - "extensions are Android only, see below." - ) - ) else desktop.map { plugin -> - val installedVersion = installed.firstOrNull { it.internalName == plugin.internalName }?.version - Preference.PreferenceItem.TextPreference( - title = plugin.name, - subtitle = failures[plugin.internalName] - ?: listOfNotNull( - plugin.description, - plugin.language?.uppercase(), - "v${plugin.version}", - "Down".takeIf { plugin.status == 0 }, - ).joinToString(" · "), - widget = { - BusyOr(busy[plugin.internalName] == true) { - fun run(action: suspend () -> Unit) { - busy[plugin.internalName] = true - failures.remove(plugin.internalName) - scope.launch { - runCatching { action() }.onFailure { - failures[plugin.internalName] = "Failed: ${it.message ?: it}" - } - busy.remove(plugin.internalName) - } - } - when { - installedVersion == null -> Button(onClick = { - run { manager.install(url, plugin) } - }) { Text("Install") } - - installedVersion < plugin.version -> Button(onClick = { - run { manager.install(url, plugin) } - }) { Text("Update") } + val sorted = current.plugins.sortedBy { it.name.lowercase() } + val desktop = sorted.filter { it.supportsDesktop } + val android = sorted.filter { it.needsConversion } + val unavailable = sorted.filterNot { it.canInstall } - else -> OutlinedButton(onClick = { - run { manager.uninstall(plugin.internalName) } - }) { Text("Uninstall") } + @Composable + fun row(plugin: RepoPlugin): Preference.PreferenceItem<*, *> { + val installedVersion = installed.firstOrNull { it.internalName == plugin.internalName }?.version + return Preference.PreferenceItem.TextPreference( + // Like Android, a provider that is down says so in its name + title = displayName(plugin.name) + if (plugin.status == 0) " (disabled)" else "", + icon = extensionIcon(plugin.iconUrl), + subtitle = failures[plugin.internalName] + ?: listOfNotNull( + plugin.description, + languageName(plugin.language), + "v${plugin.version}", + fileSize(if (plugin.supportsDesktop) plugin.jarFileSize else plugin.androidFileSize), + ).joinToString(" · "), + widget = { + BusyOr(busy[plugin.internalName] == true, label = "Converting".takeIf { plugin.needsConversion }) { + fun run(action: suspend () -> Unit) { + busy[plugin.internalName] = true + failures.remove(plugin.internalName) + scope.launch { + runCatching { action() }.onFailure { + failures[plugin.internalName] = "Failed: ${it.message ?: it}" } + busy.remove(plugin.internalName) } - }, - ) + } + when { + installedVersion == null -> Button(onClick = { + run { manager.install(url, plugin) } + }) { Text("Install") } + + installedVersion < plugin.version -> Button(onClick = { + run { manager.install(url, plugin) } + }) { Text("Update") } + + else -> OutlinedButton(onClick = { + run { manager.uninstall(plugin.internalName) } + }) { Text("Uninstall") } + } + } }, + ) + } + + // Product Sans draws "(5)" as a circled digit, so counts use a separator instead of parentheses + listOfNotNull( + if (desktop.isEmpty()) null else Preference.PreferenceGroup( + title = "Desktop builds · ${desktop.size}", + preferenceItems = desktop.map { row(it) }, ), - if (androidOnly.isEmpty()) null else Preference.PreferenceGroup( - title = "Android only · ${androidOnly.size}", + if (android.isEmpty()) null else Preference.PreferenceGroup( + title = "Android builds · ${android.size}", preferenceItems = listOf( Preference.PreferenceItem.InfoPreference( - "These extensions have no desktop build yet. Their authors can publish one by setting " + - "isCrossPlatform = true in the extension's build.gradle.kts." + "These extensions were built for Android. Desktop converts them when you install " + + "them, which takes a few seconds. Searching and playing work for most; their " + + "settings screens and anything that needs Android's WebView do not." ) - ) + androidOnly.map { plugin -> + ) + android.map { row(it) }, + ), + if (unavailable.isEmpty()) null else Preference.PreferenceGroup( + title = "Unavailable · ${unavailable.size}", + preferenceItems = unavailable.map { plugin -> Preference.PreferenceItem.TextPreference( - title = plugin.name, - subtitle = plugin.description, + title = displayName(plugin.name), + subtitle = "The repository lists no file to install", enabled = false, ) }, @@ -335,9 +387,12 @@ private fun megaRepoGroup(scope: CoroutineScope): Preference.PreferenceGroup { } @Composable -private fun BusyOr(busy: Boolean, content: @Composable () -> Unit) { +private fun BusyOr(busy: Boolean, label: String? = null, content: @Composable () -> Unit) { if (busy) { - CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + label?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) + } } else { content() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 343e7d503b6..4f0dfaf60a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ androidGradlePlugin = "9.1.1" animeDb = "1.0.2" annotation = "1.10.0" appcompat = "1.7.1" +asm = "9.10.1" # Same version dex2jar brings in biometric = "1.4.0-alpha07" buildkonfigGradlePlugin = "0.21.2" coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later @@ -15,9 +16,11 @@ constraintlayout = "2.2.1" coreKtx = "1.18.0" cryptography = "0.6.0" desugar_jdk_libs_nio = "2.1.5" +dex2jar = "2.4.38" dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" +gson = "2.14.0" instancioCore = "5.6.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20260522" @@ -79,6 +82,7 @@ activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activ anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeDb" } annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "asm" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } @@ -92,9 +96,11 @@ cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", ver cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" } databinding = { module = "androidx.databinding:viewbinding", version.ref = "androidGradlePlugin" } desugar_jdk_libs_nio = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs_nio" } +dex2jar = { module = "de.femtopedia.dex2jar:dex-translator", version.ref = "dex2jar" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } instancio-core = { group = "org.instancio", name = "instancio-core", version.ref = "instancioCore" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } json = { module = "org.json:json", version.ref = "json" } diff --git a/settings.gradle.kts b/settings.gradle.kts index dd92448b069..332adf8b63b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,4 +18,4 @@ dependencyResolutionManagement { } rootProject.name = "CloudStream" -include(":app", ":shared", ":library", ":docs", ":desktopApp") +include(":app", ":shared", ":library", ":docs", ":desktopApp", ":androidCompat") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ee43880417c..4a79b62770a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -50,6 +50,7 @@ kotlin { } jvmMain.dependencies { + api(project(":androidCompat")) implementation(libs.ktor.client.java) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) @@ -70,4 +71,8 @@ compose.resources { publicResClass = true packageOfResClass = "com.lagradost.cloudstream4.generated.resources" generateResClass = auto -} \ No newline at end of file +} +// The Android extensions test converts and loads hundreds of extensions in one run +tasks.withType().configureEach { + maxHeapSize = "3g" +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt index 3f2d4ed6778..362070d67f7 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -1,6 +1,11 @@ package com.lagradost.cloudstream4.plugins +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.plugins.PluginData +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.lagradost.cloudstream4.android.DexConverter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,6 +18,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -33,11 +39,20 @@ data class InstalledPlugin( val name: String, val version: Int, val repositoryUrl: String, + /** Converted from the Android version, whose .cs3 is kept in the android folder */ + val isAndroid: Boolean = false, + /** The converter version that made the jar, see CONVERTER_VERSION */ + val convertedWith: Int = 0, + /** From the repository, with %size% where the size goes like Android */ + val iconUrl: String? = null, ) /** * Installs plugin jars into one folder, keeps a list of them in installed.json and loads them. * All changes go through one lock, so an install and an uninstall can never interleave. + * + * A plugin only built for Android is downloaded as its .cs3 and converted into a jar. The .cs3 is + * kept, so a later app version with a better converter converts it again without a download. */ class DesktopPluginManager( private val dir: Path, @@ -65,31 +80,59 @@ class DesktopPluginManager( } private fun jarFile(internalName: String): Path = dir.resolve("$internalName.jar") + private fun androidFile(internalName: String): Path = dir.resolve("android").resolve("$internalName.cs3") + + init { + // Android plugins look themselves up in the Android app's plugin manager + PluginManager.installed = { + _installed.value.map { PluginData(it.internalName, it.repositoryUrl, true, jarFile(it.internalName).toString(), it.version) } + } + } /** Loads every installed plugin, called once at startup. A plugin that fails is skipped and its error kept */ - suspend fun loadAll() = lock.withLock { - withContext(Dispatchers.IO) { - for (plugin in _installed.value) { - if (loaded.containsKey(plugin.internalName)) continue - loadLocked(plugin.internalName) + suspend fun loadAll() { + lock.withLock { + withContext(Dispatchers.IO) { + for (plugin in _installed.value) { + if (loaded.containsKey(plugin.internalName)) continue + if (plugin.isAndroid && plugin.convertedWith != CONVERTER_VERSION) reconvertLocked(plugin) + loadLocked(plugin.internalName) + } } } + // Android plugins wait for this to register things that need the other plugins + runCatching { MainActivity.afterPluginsLoadedEvent.invoke(false) } + .onFailure { println("ERROR DesktopPluginManager: A plugin failed in afterPluginsLoadedEvent: $it") } } - /** Downloads, verifies and loads a plugin, replacing an older version that is already installed */ + /** + * Downloads, verifies and loads a plugin, replacing an older version that is already installed. + * A plugin only built for Android is converted first, which takes a few seconds for large ones. + */ suspend fun install(repositoryUrl: String, plugin: RepoPlugin) { - val bytes = client.downloadJar(plugin) + val android = !plugin.supportsDesktop + val bytes = if (android) client.downloadAndroidPlugin(plugin) else client.downloadJar(plugin) lock.withLock { withContext(Dispatchers.IO) { dir.createDirectories() loaded.remove(plugin.internalName)?.unload() publishLoaded() val target = jarFile(plugin.internalName) - val tmp = dir.resolve("${plugin.internalName}.jar.tmp") - tmp.writeBytes(bytes) - Files.move(tmp, target, REPLACE_EXISTING, ATOMIC_MOVE) - - val entry = InstalledPlugin(plugin.internalName, plugin.name, plugin.version, repositoryUrl) + if (android) { + val cs3 = androidFile(plugin.internalName) + cs3.parent.createDirectories() + writeAtomically(cs3, bytes) + convert(plugin.internalName, cs3, target) + } else { + writeAtomically(target, bytes) + androidFile(plugin.internalName).deleteIfExists() + } + + val entry = InstalledPlugin( + plugin.internalName, plugin.name, plugin.version, repositoryUrl, + isAndroid = android, convertedWith = if (android) CONVERTER_VERSION else 0, + iconUrl = plugin.iconUrl, + ) writeIndex(_installed.value.filterNot { it.internalName == plugin.internalName } + entry) loadLocked(plugin.internalName)?.let { throw it } } @@ -101,11 +144,42 @@ class DesktopPluginManager( loaded.remove(internalName)?.unload() publishLoaded() jarFile(internalName).deleteIfExists() + androidFile(internalName).deleteIfExists() writeIndex(_installed.value.filterNot { it.internalName == internalName }) _errors.update { it - internalName } } } + private fun writeAtomically(target: Path, bytes: ByteArray) { + val tmp = target.resolveSibling("${target.fileName}.tmp") + tmp.writeBytes(bytes) + Files.move(tmp, target, REPLACE_EXISTING, ATOMIC_MOVE) + } + + private fun convert(internalName: String, cs3: Path, target: Path) { + val start = System.nanoTime() + val result = try { + DexConverter.convert(cs3, target) + } catch (e: OutOfMemoryError) { + throw PluginDownloadException("$internalName is too large to convert with the memory the app has") + } catch (e: Exception) { + throw PluginDownloadException("Could not convert $internalName for desktop: ${e.message ?: e}") + } + val millis = (System.nanoTime() - start) / 1_000_000 + println("INFO DesktopPluginManager: Converted $internalName in $millis ms, ${result.failedMethods} methods failed") + } + + /** Converts a plugin again after the converter changed. Without its .cs3 the old jar is kept */ + private fun reconvertLocked(plugin: InstalledPlugin) { + val cs3 = androidFile(plugin.internalName) + if (!cs3.exists()) return + runCatching { convert(plugin.internalName, cs3, jarFile(plugin.internalName)) } + .onSuccess { + writeIndex(_installed.value.map { if (it.internalName == plugin.internalName) it.copy(convertedWith = CONVERTER_VERSION) else it }) + } + .onFailure { println("ERROR DesktopPluginManager: Could not convert ${plugin.internalName} again: $it") } + } + /** Returns the error if the plugin could not be loaded */ private fun loadLocked(internalName: String): Throwable? { val file = jarFile(internalName) @@ -133,6 +207,9 @@ class DesktopPluginManager( name = obj["name"]!!.jsonPrimitive.content, version = obj["version"]!!.jsonPrimitive.int, repositoryUrl = obj["repositoryUrl"]!!.jsonPrimitive.content, + isAndroid = obj["isAndroid"]?.jsonPrimitive?.boolean ?: false, + convertedWith = obj["convertedWith"]?.jsonPrimitive?.int ?: 0, + iconUrl = obj["iconUrl"]?.jsonPrimitive?.takeIf { it.isString }?.content, ) } }.getOrElse { @@ -149,6 +226,9 @@ class DesktopPluginManager( "name" to JsonPrimitive(it.name), "version" to JsonPrimitive(it.version), "repositoryUrl" to JsonPrimitive(it.repositoryUrl), + "isAndroid" to JsonPrimitive(it.isAndroid), + "convertedWith" to JsonPrimitive(it.convertedWith), + "iconUrl" to JsonPrimitive(it.iconUrl), ) ) }) @@ -159,7 +239,13 @@ class DesktopPluginManager( } companion object { + /** Raise when the conversion changes, so Android plugins installed before are converted again */ + const val CONVERTER_VERSION = 1 + /** The app's plugin manager, keeping plugins in the plugins folder next to the settings */ - val instance: DesktopPluginManager by lazy { DesktopPluginManager(AppDirs.root.resolve("plugins")) } + val instance: DesktopPluginManager by lazy { + DesktopAndroid.init(AppDirs.root.resolve("android")) + DesktopPluginManager(AppDirs.root.resolve("plugins")) + } } } diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt index 022210639c8..15000d01301 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt @@ -4,11 +4,13 @@ import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.plugins.BasePlugin import com.lagradost.cloudstream3.plugins.CloudstreamPlugin +import com.lagradost.cloudstream3.plugins.Plugin import com.lagradost.cloudstream3.utils.extractorApis +import com.lagradost.cloudstream4.android.AndroidPluginClassLoader +import com.lagradost.cloudstream4.android.DesktopAndroid import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.net.URLClassLoader import java.nio.file.Path import java.util.jar.JarFile @@ -16,8 +18,11 @@ import java.util.jar.JarFile class LoadedPlugin internal constructor( val file: Path, val instance: BasePlugin, - private val classLoader: URLClassLoader, + private val classLoader: AndroidPluginClassLoader, ) { + /** Android classes the plugin used that desktop only has empty stubs of, so those parts do nothing */ + val stubbedClasses: Set get() = classLoader.stubbedClasses + /** The providers this plugin registered when it loaded */ val providers: List get() = APIHolder.allProviders.withLock { APIHolder.allProviders.filter { it.sourcePlugin == instance.filename } } @@ -37,16 +42,18 @@ class LoadedPlugin internal constructor( } /** - * Loads the cross-platform .jar a plugin publishes when it is built with `isCrossPlatform = true`. + * Loads a plugin jar: the cross-platform .jar a plugin publishes when it is built with + * `isCrossPlatform = true`, or one converted from an Android plugin by DexConverter. * - * Unlike the Android .cs3 file, the jar has no manifest.json, so the plugin class is the one - * annotated with @CloudstreamPlugin. A manifest.json is still used if a jar has one. + * The cross-platform jar has no manifest.json, so the plugin class is the one annotated with + * @CloudstreamPlugin. A converted jar keeps the Android plugin's manifest.json, which is used first. + * Android plugins get load(context) with the desktop activity, like Android calls it with its activity. */ object PluginLoader { private const val ANNOTATION_DESCRIPTOR = "Lcom/lagradost/cloudstream3/plugins/CloudstreamPlugin;" fun load(file: Path, parent: ClassLoader = PluginLoader::class.java.classLoader): LoadedPlugin { - val classLoader = URLClassLoader(arrayOf(file.toUri().toURL()), parent) + val classLoader = AndroidPluginClassLoader(file, parent) try { val className = findPluginClassName(file) ?: throw IllegalArgumentException("No class annotated with @CloudstreamPlugin in $file") @@ -58,7 +65,7 @@ object PluginLoader { instance.filename = file.toAbsolutePath().toString() val loaded = LoadedPlugin(file, instance, classLoader) try { - instance.load() + if (instance is Plugin) instance.load(DesktopAndroid.activity) else instance.load() } catch (t: Throwable) { loaded.unload() throw t diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt index 53fc13925cf..50fc739cc71 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt @@ -36,13 +36,23 @@ data class RepoPlugin( val language: String?, val tvTypes: List, val iconUrl: String?, - /** Only set when the plugin is built with `isCrossPlatform = true`, which desktop needs */ + /** Only set when the plugin is built with `isCrossPlatform = true` */ val jarUrl: String?, /** "sha256-" */ val jarHash: String?, val jarFileSize: Long?, + /** The Android version (.cs3), which desktop converts when there is no jar */ + val androidUrl: String? = null, + val androidHash: String? = null, + val androidFileSize: Long? = null, ) { + /** Built for desktop too, so it runs as it is */ val supportsDesktop: Boolean get() = jarUrl != null + + /** Only built for Android, so it is converted on install */ + val needsConversion: Boolean get() = jarUrl == null && androidUrl != null + + val canInstall: Boolean get() = supportsDesktop || needsConversion } class PluginDownloadException(message: String) : Exception(message) @@ -125,20 +135,31 @@ class RepositoryClient( /** Downloads the plugin jar and checks its size and hash against the repository */ suspend fun downloadJar(plugin: RepoPlugin): ByteArray { val url = plugin.jarUrl ?: throw PluginDownloadException("${plugin.name} has no desktop version") - val bytes = getBytes(url) - plugin.jarFileSize?.let { expected -> + return getBytes(url).also { verify(plugin, it, plugin.jarFileSize, plugin.jarHash) } + } + + /** + * Downloads the plugin's Android version (.cs3) and checks its hash when the repository lists one. + * The size is not checked, because the Android app only shows it and repositories do not keep it exact. + */ + suspend fun downloadAndroidPlugin(plugin: RepoPlugin): ByteArray { + val url = plugin.androidUrl ?: throw PluginDownloadException("${plugin.name} has no Android version") + return getBytes(url).also { verify(plugin, it, size = null, hash = plugin.androidHash) } + } + + private fun verify(plugin: RepoPlugin, bytes: ByteArray, size: Long?, hash: String?) { + size?.let { expected -> if (bytes.size.toLong() != expected) { throw PluginDownloadException("${plugin.name}: expected $expected bytes, got ${bytes.size}") } } - plugin.jarHash?.let { expected -> + hash?.let { expected -> val actual = "sha256-" + MessageDigest.getInstance("SHA-256").digest(bytes) .joinToString("") { "%02x".format(it) } if (!actual.equals(expected, ignoreCase = true)) { throw PluginDownloadException("${plugin.name}: the download does not match the repository's hash") } } - return bytes } private suspend fun getText(url: String): String = getBytes(url).decodeToString() @@ -182,6 +203,9 @@ class RepositoryClient( jarUrl = obj.string("jarUrl"), jarHash = obj.string("jarHash"), jarFileSize = obj.primitive("jarFileSize")?.longOrNull, + androidUrl = obj.string("url"), + androidHash = obj.string("fileHash"), + androidFileSize = obj.primitive("fileSize")?.longOrNull, ) } diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt new file mode 100644 index 00000000000..00bee7cfbc5 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt @@ -0,0 +1,163 @@ +package com.lagradost.cloudstream4.plugins + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.lagradost.cloudstream4.android.DexConverter +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.writeText +import kotlin.test.Test + +/** + * Converts, loads and searches with every Android extension (.cs3) in a folder, and reports how + * many work on desktop. It needs the internet and the extensions, so it only runs when + * CLOUDSTREAM_ANDROID_EXTENSIONS is set to the folder. The report goes to build/android-extensions. + */ +class AndroidExtensionsTest { + private enum class Outcome { + /** A provider returned search results */ + WORKS, + /** Searches ran but found nothing, which is often a site that blocks or changed */ + NO_RESULTS, + /** The site or network failed, not desktop */ + SITE_ERROR, + /** A class or member desktop lacks */ + MISSING_ON_DESKTOP, + /** Loaded, but registered no provider to search with, such as extractor-only plugins */ + NO_PROVIDER, + LOAD_FAILED, + CONVERSION_FAILED, + } + + private class Report(val name: String) { + var outcome = Outcome.LOAD_FAILED + var detail = "" + var convertMillis = 0L + var failedMethods = 0 + var providers = listOf() + var results = 0 + var stubbed = setOf() + } + + private fun Throwable.isMissingOnDesktop(): Boolean = + generateSequence(this) { it.cause }.any { it is LinkageError || it is AbstractMethodError || it is ClassCastException } + + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + @Test + fun androidExtensionsWorkOnDesktop() = runBlocking { + val folder = System.getenv("CLOUDSTREAM_ANDROID_EXTENSIONS") ?: return@runBlocking println("Skipped, set CLOUDSTREAM_ANDROID_EXTENSIONS") + // Common words in a few languages and a popular title, since sites search in their own language + val queries = System.getenv("CLOUDSTREAM_SEARCH_QUERY")?.split(',') ?: listOf("the", "a", "naruto", "love") + // The app has Swing's main dispatcher, extensions use Dispatchers.Main when they load + Dispatchers.setMain(newSingleThreadContext("Test main")) + val work = Files.createTempDirectory("cs-android-extensions") + DesktopAndroid.init(work.resolve("android")) + val out = Path.of("build", "android-extensions").createDirectories() + val files = Path.of(folder).listDirectoryEntries("*.cs3").sortedBy { it.name } + println("Testing ${files.size} extensions, searching for $queries") + + // Convert and load one at a time, plugins register into one global list + val reports = files.map { cs3 -> + val report = Report(cs3.name.removeSuffix(".cs3")) + val jar = work.resolve(cs3.name.removeSuffix(".cs3") + ".jar") + try { + val start = System.nanoTime() + report.failedMethods = DexConverter.convert(cs3, jar).failedMethods + report.convertMillis = (System.nanoTime() - start) / 1_000_000 + } catch (t: Throwable) { + report.outcome = Outcome.CONVERSION_FAILED + report.detail = t.toString() + return@map report to null + } + val loaded = try { + PluginLoader.load(jar) + } catch (t: Throwable) { + report.outcome = if (t.isMissingOnDesktop()) Outcome.MISSING_ON_DESKTOP else Outcome.LOAD_FAILED + report.detail = "load: $t" + return@map report to null + } + report.providers = loaded.providers.map { it.name } + report to loaded + } + + val searches = Semaphore(12) + reports.map { (report, loaded) -> + async(Dispatchers.IO) { + if (loaded == null) return@async + val providers = loaded.providers + if (providers.isEmpty()) { + report.outcome = Outcome.NO_PROVIDER + return@async + } + val errors = mutableListOf() + for (provider in providers) { + for (query in queries) { + val items = searches.withPermit { + try { + withTimeout(30_000) { provider.search(query, 1)?.items.orEmpty() } + } catch (t: Throwable) { + errors += t + emptyList() + } + } + report.results += items.size + if (items.isNotEmpty()) break + } + } + report.outcome = when { + report.results > 0 -> Outcome.WORKS + errors.any { it.isMissingOnDesktop() } -> Outcome.MISSING_ON_DESKTOP + errors.isNotEmpty() -> Outcome.SITE_ERROR + else -> Outcome.NO_RESULTS + } + report.detail = (errors.firstOrNull { it.isMissingOnDesktop() } ?: errors.firstOrNull())?.let { + it.toString() + " at " + it.stackTrace.take(3).joinToString(" < ") + } ?: "" + } + }.awaitAll() + for ((report, loaded) in reports) { + report.stubbed = loaded?.stubbedClasses.orEmpty() + runCatching { loaded?.unload() } + } + + val all = reports.map { it.first } + val json = JsonArray(all.map { + JsonObject( + mapOf( + "name" to JsonPrimitive(it.name), + "outcome" to JsonPrimitive(it.outcome.name), + "detail" to JsonPrimitive(it.detail), + "convertMillis" to JsonPrimitive(it.convertMillis), + "failedMethods" to JsonPrimitive(it.failedMethods), + "providers" to JsonArray(it.providers.map(::JsonPrimitive)), + "results" to JsonPrimitive(it.results), + "stubbed" to JsonArray(it.stubbed.sorted().map(::JsonPrimitive)), + ) + ) + }) + out.resolve("report.json").writeText(json.toString()) + val counts = all.groupingBy { it.outcome }.eachCount() + println("Results for ${all.size} extensions:") + Outcome.entries.forEach { println(" ${it.name}: ${counts[it] ?: 0}") } + println("Report: ${out.resolve("report.json").toAbsolutePath()}") + work.toFile().deleteRecursively() + APIHolder.allProviders.clear() + } +} From ab19b9cb929036037ad2bf3e3ff44c5843c68dea Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 14:20:15 +0530 Subject: [PATCH 10/64] Desktop: extension pages, updates and fresh start - Extensions is now a menu of three pages: Repositories, Installed extensions and Add repository. - Repositories are listed by the name in their repo.json, kept so the list shows them right away, or "Repo (owner)" until it is known. - Installed extensions checks their repositories for new versions, with Update and Update all, and updates them at startup unless turned off. - Settings has Updates: the app finds newer releases on GitHub, checks the installer's SHA-256 and installs it with msiexec. It also asks at startup unless turned off. - Settings has Storage: clear the cache, remove all extensions or erase everything, done on a restart because loaded extensions keep their jars locked. - After CloudStream is installed again, it asks whether to keep the old data or start fresh. Updates made through the app do not ask. - Stubs of the app's sync providers extend SyncAPI as in the app, which CineStream needs to load. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/android/StubHierarchy.kt | 9 + desktopApp/build.gradle.kts | 2 + .../kotlin/com/lagradost/cloudstream4/Main.kt | 72 ++++- .../settings/AppMaintenanceSettings.kt | 253 ++++++++++++++++++ .../cloudstream4/settings/DesktopSettings.kt | 2 +- .../settings/ExtensionsSettings.kt | 240 +++++++++++++++-- .../drawable/ic_baseline_add_24.xml | 8 + .../drawable/ic_baseline_storage_24.xml | 10 + .../drawable/ic_baseline_system_update_24.xml | 5 + .../com/lagradost/cloudstream4/AppData.kt | 158 +++++++++++ .../plugins/DesktopPluginManager.kt | 31 +++ .../cloudstream4/update/AppUpdater.kt | 125 +++++++++ .../com/lagradost/cloudstream4/AppDataTest.kt | 119 ++++++++ 13 files changed, 1006 insertions(+), 28 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_add_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_storage_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_system_update_24.xml create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt index 9c30ad8810c..7ec049cf6e4 100644 --- a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/StubHierarchy.kt @@ -127,6 +127,15 @@ internal object StubHierarchy { extends("androidx/recyclerview/widget/ItemTouchHelper\$Callback", "androidx/recyclerview/widget/ItemTouchHelper\$SimpleCallback") extends("android/widget/BaseAdapter", "android/widget/ArrayAdapter") + // CloudStream app account and sync providers, which some extensions read + val sync = "com/lagradost/cloudstream3/syncproviders" + extends("$sync/AuthAPI", "$sync/SyncAPI", "$sync/SubtitleAPI", "$sync/BackupAPI") + extends("$sync/SyncAPI", "$sync/providers/AniListApi", "$sync/providers/MALApi", "$sync/providers/KitsuApi", + "$sync/providers/SimklApi") + extends("$sync/SubtitleAPI", "$sync/providers/OpenSubtitlesApi", "$sync/providers/SubDlApi", + "$sync/providers/SubSourceApi") + extends("$sync/AuthRepo", "$sync/SyncRepo", "$sync/SubtitleRepo") + // Lifecycle extends("androidx/lifecycle/LiveData", "androidx/lifecycle/MutableLiveData") extends("androidx/lifecycle/MutableLiveData", "androidx/lifecycle/MediatorLiveData") diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 1c421bdc872..a78e9cb4513 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -100,6 +100,8 @@ tasks.matching { it.name == "prepareAppResources" }.configureEach { dependsOn(as compose.desktop { application { mainClass = "com.lagradost.cloudstream4.MainKt" + // Read by AppVersion, for the update check + jvmArgs("-Dcloudstream.version=${libs.versions.versionName.get()}") nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "CloudStream" diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 86a2d3f1568..e124eb04f7f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -1,6 +1,14 @@ package com.lagradost.cloudstream4 +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -10,6 +18,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindow +import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.Window @@ -21,6 +34,8 @@ 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.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.settings.AppUpdatePrompt +import com.lagradost.cloudstream4.settings.autoUpdateExtensions import com.lagradost.cloudstream4.settings.desktopPrimaryColor import com.lagradost.cloudstream4.settings.desktopThemeMode import com.lagradost.cloudstream4.theme.CloudStreamTheme @@ -32,10 +47,26 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource fun main() { + // Before anything opens the app's files, which a reset may delete + val appData = AppData.instance + appData.runPendingReset() + if (appData.isReinstallWithOldData() && askToStartFresh()) appData.reset(AppData.Reset.EVERYTHING) + appData.rememberInstall() + DesktopLog.install(AppDirs.logs) // Plugins load in the background so a slow or broken plugin never delays the window @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { DesktopPluginManager.instance.loadAll() } + GlobalScope.launch(Dispatchers.IO) { + val manager = DesktopPluginManager.instance + manager.loadAll() + if (autoUpdateExtensions.get()) { + val updates = runCatching { manager.findUpdates() }.getOrDefault(emptyList()) + if (updates.isNotEmpty()) { + val failed = manager.installUpdates(updates) + println("INFO Main: Updated ${updates.size - failed.size} extensions" + if (failed.isEmpty()) "" else ", failed: $failed") + } + } + } application { setSingletonImageLoaderFactory(::desktopImageLoader) val savedWindow = remember { SavedWindowState(desktopPreferences) } @@ -57,6 +88,44 @@ fun main() { } } +/** + * After CloudStream was installed again, asks whether to keep the data from before or start fresh. + * Shown in a window of its own before the app opens anything. Closing it keeps the data. + */ +private fun askToStartFresh(): Boolean { + var fresh = false + application(exitProcessOnExit = false) { + DialogWindow( + onCloseRequest = ::exitApplication, + state = rememberDialogState(size = DpSize(480.dp, 260.dp)), + title = "CloudStream", + icon = painterResource(Res.drawable.default_icon), + resizable = false, + ) { + CloudStreamTheme { + Surface(color = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground) { + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Welcome back", style = MaterialTheme.typography.titleLarge) + Text( + "CloudStream was installed again and found your data from before: settings, repositories " + + "and extensions. Keep it, or start fresh as if this were a new install?", + modifier = Modifier.weight(1f), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = ::exitApplication) { Text("Keep my data") } + OutlinedButton(onClick = { + fresh = true + exitApplication() + }) { Text("Start fresh") } + } + } + } + } + } + } + return fresh +} + /** Puts the window in full screen while the player asks for it, then back to how it was */ @Composable private fun FullscreenEffect(navigator: AppNavigator, windowState: WindowState) { @@ -85,6 +154,7 @@ private fun MainContent(navigator: AppNavigator) { contentColor = MaterialTheme.colorScheme.onBackground, ) { AppShell(navigator) + AppUpdatePrompt() } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt new file mode 100644 index 00000000000..a27a998851a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt @@ -0,0 +1,253 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.AppData +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.AppVersion +import com.lagradost.cloudstream4.desktopPreferences +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.update.AppRelease +import com.lagradost.cloudstream4.update.AppUpdater +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import java.nio.file.Path +import kotlin.system.exitProcess + +/** Looks for a newer app version when the app starts */ +val checkAppUpdates = desktopPreferences.getBoolean("desktop_check_app_updates", true) + +private val updater = AppUpdater() + +/** + * Starts the app again. Installed, the executable is started before this one quits; the new one + * waits for files this one holds when it erases data. Run from a build, the app only quits. + */ +fun restartApp(): Nothing { + System.getProperty("jpackage.app-path")?.let { exe -> runCatching { ProcessBuilder(exe).start() } } + exitProcess(0) +} + +/** Downloading and installing a release, shared by the Updates screen and the prompt at startup */ +private class UpdateState { + var progress by mutableStateOf(null) + var error by mutableStateOf(null) + + suspend fun install(release: AppRelease) { + error = null + progress = 0f + try { + val installer = updater.download(release, Path.of(System.getProperty("java.io.tmpdir"), "CloudStream-update")) { progress = it } + updater.startInstaller(installer) + // The installer replaces the app's files, which it can only do once the app has quit + exitProcess(0) + } catch (t: Throwable) { + error = "Could not update: ${t.message ?: t}" + progress = null + } + } +} + +@Composable +private fun UpdateActions(release: AppRelease, state: UpdateState, onLater: (() -> Unit)? = null) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + state.progress?.let { + Text("Downloading the installer, CloudStream closes and updates when it is done", style = MaterialTheme.typography.bodySmall) + LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth()) + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + if (state.progress == null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { scope.launch { state.install(release) } }) { Text("Update now") } + OutlinedButton(onClick = { runCatching { uriHandler.openUri(release.pageUrl) } }) { Text("What's new") } + onLater?.let { TextButton(onClick = it) { Text("Later") } } + } + } +} + +/** Asks to update when a newer version is out, once per start, if the user has not turned the check off */ +@Composable +fun AppUpdatePrompt() { + var release by remember { mutableStateOf(null) } + val state = remember { UpdateState() } + LaunchedEffect(Unit) { + if (!checkAppUpdates.get()) return@LaunchedEffect + release = runCatching { updater.findUpdate() } + .onFailure { println("WARNING AppUpdatePrompt: Could not check for an update: $it") } + .getOrNull() + } + val current = release ?: return + AlertDialog( + onDismissRequest = { if (state.progress == null) release = null }, + title = { Text("CloudStream ${current.version} is available") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("You have version ${AppVersion.current}.") + UpdateActions(current, state, onLater = { release = null }) + } + }, + confirmButton = {}, + ) +} + +object SettingsUpdatesScreen : SearchableSettings { + private sealed interface Check { + data object NotChecked : Check + data object Checking : Check + data class Done(val release: AppRelease?, val error: String? = null) : Check + } + + private var check by mutableStateOf(Check.NotChecked) + private val state = UpdateState() + + @Composable + override fun getTitleRes(): String = "Updates" + + @Composable + override fun getPreferences(): List { + val scope = rememberCoroutineScope() + fun checkNow() { + check = Check.Checking + scope.launch { + check = runCatching { Check.Done(updater.findUpdate()) } + .getOrElse { Check.Done(null, "Could not check: ${it.message ?: it}") } + } + } + return listOf( + Preference.PreferenceGroup( + title = "CloudStream", + preferenceItems = listOf( + Preference.PreferenceItem.CustomPreference("Version") { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val current = check + Text( + "Version ${AppVersion.current}" + when { + current is Check.Checking -> " · checking for updates" + current is Check.Done && current.error != null -> " · ${current.error}" + current is Check.Done && current.release == null -> " · up to date" + current is Check.Done -> " · version ${current.release!!.version} is available" + else -> "" + }, + modifier = Modifier.weight(1f), + ) + if ((check as? Check.Done)?.release == null) { + OutlinedButton(enabled = check != Check.Checking, onClick = ::checkNow) { Text("Check now") } + } + } + (check as? Check.Done)?.release?.let { UpdateActions(it, state) } + } + }, + Preference.PreferenceItem.SwitchPreference( + preference = checkAppUpdates, + title = "Check for updates when the app starts", + subtitle = "New versions come from github.com/${AppUpdater.REPOSITORY}", + ), + ), + ), + Preference.PreferenceGroup( + title = "Extensions", + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + preference = autoUpdateExtensions, + title = "Update extensions automatically", + subtitle = "Installs new versions of your extensions when the app starts", + ), + ), + ), + ) + } +} + +/** Clearing the cache, removing extensions or erasing everything, each confirmed and done on a restart */ +object SettingsStorageScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Storage" + + @Composable + override fun getPreferences(): List { + var confirm by remember { mutableStateOf(null) } + + confirm?.let { reset -> + AlertDialog( + onDismissRequest = { confirm = null }, + title = { Text("${reset.title}?") }, + text = { + Text( + when (reset) { + AppData.Reset.CACHE -> "Posters and other downloaded files are deleted and downloaded again when needed." + AppData.Reset.EXTENSIONS -> "Every installed extension and its saved data is removed. Your repositories and settings stay." + AppData.Reset.EVERYTHING -> "Settings, repositories, extensions and everything else are deleted, as if CloudStream had just been installed." + } + " CloudStream restarts to do this." + ) + }, + confirmButton = { + Button(onClick = { + AppData.instance.scheduleReset(reset) + restartApp() + }) { Text(reset.title) } + }, + dismissButton = { TextButton(onClick = { confirm = null }) { Text("Cancel") } }, + ) + } + + fun item(reset: AppData.Reset, subtitle: String) = Preference.PreferenceItem.TextPreference( + title = reset.title, + subtitle = subtitle, + onClick = { confirm = reset }, + ) + + return listOf( + Preference.PreferenceGroup( + title = "Data", + preferenceItems = listOf( + Preference.PreferenceItem.InfoPreference("CloudStream keeps its data in ${AppDirs.root} and its cache in ${AppDirs.cache}."), + item(AppData.Reset.CACHE, "Posters and other files that can be downloaded again"), + item(AppData.Reset.EXTENSIONS, "Installed extensions and their data. Repositories and settings stay"), + item(AppData.Reset.EVERYTHING, "Start fresh, as if CloudStream had just been installed"), + ), + ), + ) + } +} + +/** The Updates and Storage entries on the settings home */ +@Composable +fun maintenanceCategories(open: (SearchableSettings) -> Unit): List = listOf( + Preference.PreferenceItem.TextPreference( + title = "Updates", + subtitle = "Version ${AppVersion.current}", + icon = painterResource(Res.drawable.ic_baseline_system_update_24), + onClick = { open(SettingsUpdatesScreen) }, + ), + Preference.PreferenceItem.TextPreference( + title = "Storage", + subtitle = "Clear the cache, remove extensions or start fresh", + icon = painterResource(Res.drawable.ic_baseline_storage_24), + onClick = { open(SettingsStorageScreen) }, + ), +) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt index 93a3d75b5ca..ba8d613174b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -47,7 +47,7 @@ class SettingsHomeScreen(private val open: (SearchableSettings) -> Unit) : Searc category(SettingsLayoutScreen, Res.drawable.format_paint_24px), category(SettingsProvidersScreen, Res.drawable.plugin_lang), category(ExtensionsScreen(open), Res.drawable.extension_24px), - ) + ) + maintenanceCategories(open) @Composable private fun category(screen: SearchableSettings, icon: org.jetbrains.compose.resources.DrawableResource) = diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt index 2a1675764e0..d040a5394cc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -32,6 +32,7 @@ import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream4.desktopPreferences import com.lagradost.cloudstream4.generated.resources.* import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.plugins.PluginUpdate import com.lagradost.cloudstream4.plugins.RepoPlugin import com.lagradost.cloudstream4.plugins.Repository import com.lagradost.cloudstream4.plugins.RepositoryClient @@ -42,6 +43,9 @@ import com.mihon.presentation.settings.SearchableSettings import com.mihon.presentation.settings.collectAsState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -49,8 +53,34 @@ import org.jetbrains.compose.resources.stringResource /** Repository URLs the user added, kept separate from the Android REPOSITORIES_KEY data store */ private val repositories = desktopPreferences.getStringSet("desktop_repositories", emptySet()) +/** Repository names from their repo.json, as "urlname", so the list shows them without loading every repository */ +private val repositoryNames = desktopPreferences.getStringSet("desktop_repository_names", emptySet()) + +/** Updates installed extensions when the app starts, like the Android app's automatic extension updates */ +val autoUpdateExtensions = desktopPreferences.getBoolean("desktop_auto_update_extensions", true) + private val client = RepositoryClient() +private fun knownNames(entries: Set): Map = entries.mapNotNull { entry -> + entry.split('\t', limit = 2).takeIf { it.size == 2 && it[1].isNotBlank() }?.let { it[0] to it[1] } +}.toMap() + +private fun rememberRepositoryName(url: String, name: String) { + if (name.isBlank() || name == url) return + val entry = "$url\t$name" + val current = repositoryNames.get() + if (entry in current) return + repositoryNames.set(current.filterNot { it.startsWith("$url\t") }.toSet() + entry) +} + +/** Until its repo.json is read, a GitHub repository is shown as "Repo (owner)" rather than the raw URL */ +private fun nameFromUrl(url: String): String { + val parts = url.removePrefix("https://").removePrefix("http://").split('/') + return if (parts.firstOrNull() == "raw.githubusercontent.com" && parts.size >= 3) "${parts[2]} (${parts[1]})" else shortUrl(url) +} + +private fun shortUrl(url: String) = url.removePrefix("https://").removePrefix("http://").removePrefix("raw.githubusercontent.com/") + /** The name as the Android app shows it, which leaves out the "Provider" most names end with */ private fun displayName(name: String) = name.removeSuffix("Provider") @@ -89,18 +119,21 @@ private suspend fun addCommunityRepositories(): Int { */ private suspend fun addRepository(input: String): String { val url = client.resolveRepositoryUrl(input) ?: return "No repository found for \"${input.trim()}\"" - val plugins = client.fetchPlugins(client.fetchRepository(url)) + val repository = client.fetchRepository(url) + val plugins = client.fetchPlugins(repository) if (plugins.isNotEmpty() && plugins.all { it.internalName == RepositoryClient.MEGA_REPO_PLUGIN }) { val added = addCommunityRepositories() return "MegaRepo: added $added community repositories" + if (added == 0) ", you already had them all" else "" } + rememberRepositoryName(url, repository.name) repositories += url - return "Added ${url.removePrefix("https://")}" + return "Added ${repository.name.takeIf { it != url } ?: shortUrl(url)}" } /** - * Repositories and installed extensions. Extensions built with `isCrossPlatform = true` run as they - * are, the others are converted from their Android build when they are installed. + * Extensions, as a menu of its three pages: repositories, installed extensions and adding a + * repository. Extensions built with `isCrossPlatform = true` run as they are, the others are + * converted from their Android build when they are installed. */ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { @Composable @@ -108,24 +141,90 @@ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : Searcha @Composable override fun getPreferences(): List { - val manager = DesktopPluginManager.instance val repos by repositories.collectAsState() - val installed by manager.installed.collectAsState() - val errors by manager.errors.collectAsState() - val loadedNames by manager.loadedNames.collectAsState() - val scope = rememberCoroutineScope() - val busy = remember { mutableStateMapOf() } - - val repoItems = repos.sorted().map { url -> + val installed by DesktopPluginManager.instance.installed.collectAsState() + val errors by DesktopPluginManager.instance.errors.collectAsState() + val icon = painterResource(Res.drawable.extension_24px) + return listOf( Preference.PreferenceItem.TextPreference( - title = url.removePrefix("https://").removePrefix("raw.githubusercontent.com/"), - subtitle = if (url == RepositoryClient.OFFICIAL_REPOSITORY) "Official repository" else url, - icon = painterResource(Res.drawable.extension_24px), - onClick = { open(RepositoryScreen(url)) }, - widget = { TextButton(onClick = { repositories -= url }) { Text("Remove") } }, - ) + title = "Repositories", + subtitle = when (repos.size) { + 0 -> "None added yet" + 1 -> "1 repository" + else -> "${repos.size} repositories" + }, + icon = icon, + onClick = { open(RepositoriesScreen(open)) }, + ), + Preference.PreferenceItem.TextPreference( + title = "Installed extensions", + subtitle = listOfNotNull( + if (installed.isEmpty()) "None installed yet" else "${installed.size} installed", + errors.size.takeIf { it > 0 }?.let { "$it failed to load" }, + ).joinToString(" · "), + icon = painterResource(Res.drawable.build_24px), + onClick = { open(InstalledExtensionsScreen) }, + ), + Preference.PreferenceItem.TextPreference( + title = "Add repository", + subtitle = "A URL, a short code such as megarepo, or all community repositories", + icon = painterResource(Res.drawable.ic_baseline_add_24), + onClick = { open(AddRepositoryScreen) }, + ), + ) + } +} + +/** The repositories the user added, by name. Opening one lists its extensions */ +class RepositoriesScreen(private val open: (SearchableSettings) -> Unit) : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Repositories" + + @Composable + override fun getPreferences(): List { + val repos by repositories.collectAsState() + val names by repositoryNames.collectAsState() + val known = knownNames(names) + + // Reads the names not known yet, a repository that fails to load keeps its name from the URL + LaunchedEffect(repos) { + val missing = repos.filter { it !in knownNames(repositoryNames.get()) } + coroutineScope { + missing.map { url -> + async { runCatching { client.fetchRepository(url) }.getOrNull()?.let { rememberRepositoryName(url, it.name) } } + }.awaitAll() + } } + if (repos.isEmpty()) return listOf( + Preference.PreferenceItem.InfoPreference("No repositories yet. Add one, or type megarepo to add all community repositories."), + Preference.PreferenceItem.TextPreference(title = "Add repository", onClick = { open(AddRepositoryScreen) }), + ) + + val items = repos.map { url -> url to (known[url] ?: nameFromUrl(url)) } + .sortedBy { it.second.lowercase() } + .map { (url, name) -> + Preference.PreferenceItem.TextPreference( + title = name, + subtitle = if (url == RepositoryClient.OFFICIAL_REPOSITORY) "Official repository · ${shortUrl(url)}" else shortUrl(url), + icon = painterResource(Res.drawable.extension_24px), + onClick = { open(RepositoryScreen(url)) }, + widget = { TextButton(onClick = { repositories -= url }) { Text("Remove") } }, + ) + } + return listOf(Preference.PreferenceGroup(title = "Repositories · ${repos.size}", preferenceItems = items)) + } +} + +/** Adding a repository by URL or short code, or the official or all community repositories */ +object AddRepositoryScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = "Add repository" + + @Composable + override fun getPreferences(): List { + val repos by repositories.collectAsState() + val scope = rememberCoroutineScope() val addRepository = Preference.PreferenceItem.CustomPreference(title = "Add repository") { var text by remember { mutableStateOf("") } var adding by remember { mutableStateOf(false) } @@ -187,16 +286,100 @@ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : Searcha } } } + return listOf(addRepository) + } +} + +/** Installed extensions, with their status, updates and uninstall */ +object InstalledExtensionsScreen : SearchableSettings { + private sealed interface Check { + data object NotChecked : Check + data object Checking : Check + data class Done(val updates: List, val message: String? = null) : Check + } + + private var check by mutableStateOf(Check.NotChecked) + + @Composable + override fun getTitleRes(): String = "Installed extensions" + + @Composable + override fun getPreferences(): List { + val manager = DesktopPluginManager.instance + val installed by manager.installed.collectAsState() + val errors by manager.errors.collectAsState() + val loadedNames by manager.loadedNames.collectAsState() + val scope = rememberCoroutineScope() + val busy = remember { mutableStateMapOf() } + + fun checkForUpdates() { + check = Check.Checking + scope.launch { + check = runCatching { Check.Done(manager.findUpdates()) } + .getOrElse { Check.Done(emptyList(), "Could not check: ${it.message ?: it}") } + } + } + + LaunchedEffect(Unit) { if (check == Check.NotChecked && installed.isNotEmpty()) checkForUpdates() } + val updates = (check as? Check.Done)?.updates.orEmpty() + // An update that was installed since the check is no longer one + .filter { update -> installed.any { it.internalName == update.installed.internalName && it.version < update.available.version } } + + val updatesGroup = Preference.PreferenceGroup( + title = "Updates", + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + preference = autoUpdateExtensions, + title = "Update extensions automatically", + subtitle = "Installs new versions of your extensions when the app starts", + ), + Preference.PreferenceItem.CustomPreference("Check for updates") { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val current = check + Text( + when { + current is Check.Checking -> "Checking your extensions' repositories" + current is Check.Done && current.message != null -> current.message + current is Check.Done && updates.isEmpty() -> "All extensions are up to date" + current is Check.Done -> if (updates.size == 1) "1 update available" else "${updates.size} updates available" + else -> "Not checked yet" + }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + ) + BusyOr(current is Check.Checking || busy["all"] == true, label = "Updating".takeIf { busy["all"] == true }) { + if (updates.isNotEmpty()) { + Button(onClick = { + busy["all"] = true + scope.launch { + val failed = manager.installUpdates(updates) + check = Check.Done(emptyList(), if (failed.isEmpty()) "Updated ${updates.size}" else "${failed.size} could not update: ${failed.values.first()}") + busy.remove("all") + } + }) { Text("Update all") } + } else { + OutlinedButton(enabled = installed.isNotEmpty(), onClick = ::checkForUpdates) { Text("Check now") } + } + } + } + }, + ), + ) - val installedItems = installed.sortedBy { it.name.lowercase() }.map { plugin -> + val items = installed.sortedBy { displayName(it.name).lowercase() }.map { plugin -> val error = errors[plugin.internalName] val loaded = if (plugin.internalName in loadedNames) manager.loadedPlugin(plugin.internalName) else null val providers = loaded?.providers?.size ?: 0 + val update = updates.firstOrNull { it.installed.internalName == plugin.internalName } Preference.PreferenceItem.TextPreference( title = displayName(plugin.name), icon = extensionIcon(plugin.iconUrl), subtitle = error?.let { "Failed to load: $it" } ?: listOfNotNull( - "Version ${plugin.version}", + "Version ${plugin.version}" + (update?.let { " · version ${it.available.version} available" } ?: ""), "Android build, converted for desktop".takeIf { plugin.isAndroid }, when (providers) { 0 -> null @@ -207,23 +390,27 @@ class ExtensionsScreen(private val open: (SearchableSettings) -> Unit) : Searcha ).joinToString(" · "), widget = { BusyOr(busy[plugin.internalName] == true) { - OutlinedButton(onClick = { + fun run(action: suspend () -> Unit) { busy[plugin.internalName] = true scope.launch { - runCatching { manager.uninstall(plugin.internalName) } + runCatching { action() } busy.remove(plugin.internalName) } - }) { Text("Uninstall") } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (update != null) Button(onClick = { run { manager.install(plugin.repositoryUrl, update.available) } }) { Text("Update") } + OutlinedButton(onClick = { run { manager.uninstall(plugin.internalName) } }) { Text("Uninstall") } + } } }, ) } return listOf( - Preference.PreferenceGroup(title = "Repositories", preferenceItems = repoItems + addRepository), + updatesGroup, Preference.PreferenceGroup( - title = "Installed", - preferenceItems = installedItems.ifEmpty { + title = "Installed · ${installed.size}", + preferenceItems = items.ifEmpty { listOf(Preference.PreferenceItem.InfoPreference("No extensions installed yet. Open a repository to install some.")) }, ), @@ -256,6 +443,7 @@ class RepositoryScreen(private val url: String) : SearchableSettings { if (state is State.Loaded) return@LaunchedEffect state = try { val repository = client.fetchRepository(url) + rememberRepositoryName(url, repository.name) State.Loaded(repository, client.fetchPlugins(repository)) } catch (e: CancellationException) { throw e diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_add_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_add_24.xml new file mode 100644 index 00000000000..200a4de6cf3 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_add_24.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_storage_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_storage_24.xml new file mode 100644 index 00000000000..27bb2f79569 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_storage_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_system_update_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_system_update_24.xml new file mode 100644 index 00000000000..a2972396f3b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_system_update_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt new file mode 100644 index 00000000000..9060304bd65 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt @@ -0,0 +1,158 @@ +package com.lagradost.cloudstream4 + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * Erasing the app's data, and noticing when the app was installed again so the user can choose to + * start fresh. + * + * Erasing happens when the app starts, before anything opens these files: a loaded extension keeps + * its jar locked on Windows, and the settings file is kept open. So the reset options in Settings + * only schedule a reset and restart the app. + */ +class AppData( + private val root: Path, + private val cache: Path, + /** The installed app's executable, null when running from a build */ + private val installedExecutable: Path? = System.getProperty("jpackage.app-path")?.let(Path::of), +) { + enum class Reset(val title: String) { + /** Posters and other downloaded files the app can fetch again */ + CACHE("Clear the cache"), + + /** Installed extensions and their data. Repositories and settings stay */ + EXTENSIONS("Remove all extensions"), + + /** Everything, as if the app had never been used */ + EVERYTHING("Erase all data"), + } + + private val pendingFile = root.resolve("reset-pending") + private val installFile = root.resolve("install-id") + private val updatingFile = root.resolve("updating") + + /** Erases the data on the next start, see [runPendingReset] */ + fun scheduleReset(reset: Reset) { + root.createDirectories() + pendingFile.writeText(reset.name) + } + + /** Erases what [scheduleReset] asked for. Call first thing at startup */ + fun runPendingReset() { + if (!pendingFile.exists()) return + val reset = runCatching { Reset.valueOf(pendingFile.readText().trim()) }.getOrNull() + pendingFile.deleteIfExists() + reset?.let(::reset) + } + + /** + * Erases right away. Only safe before the app has opened any of these files. After a restart the + * old app may still be closing and holding files, so locked files are tried again for a few seconds. + */ + fun reset(reset: Reset) { + repeat(RETRIES) { attempt -> + if (resetOnce(reset)) return + if (attempt < RETRIES - 1) Thread.sleep(500) + } + println("WARNING AppData: Some files could not be deleted for ${reset.name}") + } + + /** Returns true when everything was deleted */ + private fun resetOnce(reset: Reset): Boolean { + var complete = true + // The install id is kept, it describes the installed app, not its data + val keep = setOf(installFile.name) + fun delete(path: Path) { + if (path.exists() && !path.toFile().deleteRecursively()) complete = false + } + fun deleteContents(dir: Path) { + if (dir.isDirectory()) dir.listDirectoryEntries().forEach(::delete) + } + when (reset) { + Reset.CACHE -> { + deleteContents(cache) + deleteContents(root.resolve("android").resolve("cache")) + } + Reset.EXTENSIONS -> { + delete(root.resolve("plugins")) + delete(root.resolve("android")) + } + Reset.EVERYTHING -> { + deleteContents(cache) + if (root.isDirectory()) root.listDirectoryEntries().filter { it.name !in keep }.forEach(::delete) + } + } + return complete + } + + /** True when the app has data from earlier use, such as settings or extensions */ + fun hasData(): Boolean = root.isDirectory() && + root.listDirectoryEntries().any { it.name !in setOf(installFile.name, updatingFile.name, "logs") } + + /** Called before the app starts an installer to update itself, so the next start does not ask to start fresh */ + fun markUpdating() { + root.createDirectories() + updatingFile.writeText("1") + } + + /** + * True when the app was installed again since it last ran and data from before is there: then the + * user is asked whether to keep it. Updates through the app do not count. The first time a version + * with this check runs it only notes the install. + */ + fun isReinstallWithOldData(): Boolean { + val current = currentInstallId() ?: return false + val updating = updatingFile.exists() + val previous = if (installFile.exists()) installFile.readText().trim() else null + return previous != null && previous != current && !updating && hasData() + } + + /** Notes the current install, call after [isReinstallWithOldData] was answered */ + fun rememberInstall() { + updatingFile.deleteIfExists() + val current = currentInstallId() ?: return + root.createDirectories() + installFile.writeText(current) + } + + /** Reinstalling recreates the executable, so its creation time tells installs apart */ + private fun currentInstallId(): String? { + val exe = installedExecutable?.takeIf { it.exists() } ?: return null + return Files.readAttributes(exe, BasicFileAttributes::class.java).creationTime().toMillis().toString() + } + + companion object { + private const val RETRIES = 10 + + val instance: AppData by lazy { AppData(AppDirs.root, AppDirs.cache) } + } +} + +/** The app's version, set by the build as cloudstream.version and by the installer as jpackage.app-version */ +object AppVersion { + val current: String = + System.getProperty("cloudstream.version") ?: System.getProperty("jpackage.app-version") ?: "0.0.0" + + /** Compares dotted versions such as 4.10.0 and 4.9.2 number by number. A leading "v" is ignored */ + fun isNewer(candidate: String, than: String): Boolean { + fun parts(v: String) = v.trim().removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 } + val a = parts(candidate) + val b = parts(than) + for (i in 0 until maxOf(a.size, b.size)) { + val x = a.getOrElse(i) { 0 } + val y = b.getOrElse(i) { 0 } + if (x != y) return x > y + } + return false + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt index 362070d67f7..3d217c0bd1d 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -7,6 +7,9 @@ import com.lagradost.cloudstream4.AppDirs import com.lagradost.cloudstream4.android.DesktopAndroid import com.lagradost.cloudstream4.android.DexConverter import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,6 +50,9 @@ data class InstalledPlugin( val iconUrl: String? = null, ) +/** A newer version of an installed plugin in its repository */ +data class PluginUpdate(val installed: InstalledPlugin, val available: RepoPlugin) + /** * Installs plugin jars into one folder, keeps a list of them in installed.json and loads them. * All changes go through one lock, so an install and an uninstall can never interleave. @@ -139,6 +145,31 @@ class DesktopPluginManager( } } + /** Installed plugins with a newer version in their repository. A repository that fails to load is skipped */ + suspend fun findUpdates(): List = coroutineScope { + _installed.value.groupBy { it.repositoryUrl }.map { (url, plugins) -> + async { + val available = runCatching { client.fetchPlugins(client.fetchRepository(url)) } + .onFailure { println("WARNING DesktopPluginManager: Could not check $url for updates: $it") } + .getOrNull() ?: return@async emptyList() + plugins.mapNotNull { plugin -> + available.firstOrNull { it.internalName == plugin.internalName && it.version > plugin.version && it.canInstall } + ?.let { PluginUpdate(plugin, it) } + } + } + }.awaitAll().flatten() + } + + /** Installs the updates one after another. Returns why each one that failed did, by internal name */ + suspend fun installUpdates(updates: List): Map { + val failed = mutableMapOf() + for (update in updates) { + runCatching { install(update.installed.repositoryUrl, update.available) } + .onFailure { failed[update.installed.internalName] = it.message ?: it.toString() } + } + return failed + } + suspend fun uninstall(internalName: String) = lock.withLock { withContext(Dispatchers.IO) { loaded.remove(internalName)?.unload() diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt new file mode 100644 index 00000000000..37a52d67fad --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt @@ -0,0 +1,125 @@ +package com.lagradost.cloudstream4.update + +import com.lagradost.cloudstream4.AppData +import com.lagradost.cloudstream4.AppVersion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import okhttp3.OkHttpClient +import okhttp3.Request +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import kotlin.io.path.deleteIfExists +import kotlin.io.path.outputStream + +/** A published version of the app with its Windows installer */ +data class AppRelease( + val version: String, + val notes: String?, + val pageUrl: String, + val installerUrl: String, + val installerName: String, + /** The installer's SHA-256 as GitHub lists it, when it does */ + val sha256: String?, +) + +class AppUpdateException(message: String) : Exception(message) + +/** + * Finds newer versions of the app in the fork's GitHub releases and installs them with the release's + * MSI, which upgrades the installed app in place because every version shares one upgrade code. + */ +class AppUpdater( + private val releasesUrl: String = LATEST_RELEASE, + private val http: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build(), +) { + /** The newest release when it is newer than this app, otherwise null. Also null when nothing is published yet */ + suspend fun findUpdate(current: String = AppVersion.current): AppRelease? { + val release = latestRelease() ?: return null + return release.takeIf { AppVersion.isNewer(it.version, current) } + } + + suspend fun latestRelease(): AppRelease? = withContext(Dispatchers.IO) { + val request = Request.Builder().url(releasesUrl).header("Accept", "application/vnd.github+json").build() + http.newCall(request).execute().use { response -> + // GitHub answers 404 while the repository has no release + if (response.code == 404) return@withContext null + if (!response.isSuccessful) throw AppUpdateException("GitHub answered HTTP ${response.code}") + parseRelease(response.body.string()) + } + } + + /** Downloads the installer into [dir] and checks it against the release's hash. Returns its path */ + suspend fun download(release: AppRelease, dir: Path, onProgress: (Float) -> Unit = {}): Path = withContext(Dispatchers.IO) { + Files.createDirectories(dir) + val target = dir.resolve(release.installerName) + target.deleteIfExists() + val digest = MessageDigest.getInstance("SHA-256") + http.newCall(Request.Builder().url(release.installerUrl).build()).execute().use { response -> + if (!response.isSuccessful) throw AppUpdateException("Download failed: HTTP ${response.code}") + val total = response.body.contentLength() + var done = 0L + response.body.byteStream().use { input -> + target.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + digest.update(buffer, 0, read) + done += read + if (total > 0) onProgress(done.toFloat() / total) + } + } + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (release.sha256 != null && !actual.equals(release.sha256, ignoreCase = true)) { + target.deleteIfExists() + throw AppUpdateException("The download does not match the release's SHA-256") + } + target + } + + /** + * Starts the installer and returns, the caller then quits so the installer can replace the app. + * /passive shows only a progress bar and keeps the folder the app is installed in. + */ + fun startInstaller(installer: Path, appData: AppData = AppData.instance) { + appData.markUpdating() + ProcessBuilder("msiexec", "/i", installer.toAbsolutePath().toString(), "/passive").start() + } + + companion object { + const val REPOSITORY = "harsh-pockt/cloudstream" + const val LATEST_RELEASE = "https://api.github.com/repos/$REPOSITORY/releases/latest" + + internal fun parseRelease(text: String): AppRelease? { + val obj = Json.parseToJsonElement(text).jsonObject + val tag = obj.string("tag_name") ?: return null + val installer = obj["assets"]?.jsonArray?.map { it.jsonObject } + ?.firstOrNull { it.string("name")?.endsWith(".msi", ignoreCase = true) == true } + ?: return null + return AppRelease( + version = tag.removePrefix("v"), + notes = obj.string("body"), + pageUrl = obj.string("html_url") ?: "https://github.com/$REPOSITORY/releases", + installerUrl = installer.string("browser_download_url") ?: return null, + installerName = installer.string("name")!!, + // GitHub lists asset digests as "sha256:" + sha256 = installer.string("digest")?.takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"), + ) + } + + private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt new file mode 100644 index 00000000000..4f69cc9cffc --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt @@ -0,0 +1,119 @@ +package com.lagradost.cloudstream4 + +import com.lagradost.cloudstream4.update.AppUpdater +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AppDataTest { + private val dir: Path = Files.createTempDirectory("cs-appdata") + private val root = dir.resolve("root") + private val cache = dir.resolve("cache") + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + private fun file(path: Path) = path.also { it.parent.createDirectories(); it.writeText("x") } + + private fun fillData() { + file(root.resolve("settings.json")) + file(root.resolve("plugins/A.jar")) + file(root.resolve("android/shared_prefs/a.json")) + file(root.resolve("android/cache/c")) + file(cache.resolve("posters/p.jpg")) + } + + @Test + fun eachResetDeletesOnlyItsPart() { + fillData() + val data = AppData(root, cache, installedExecutable = null) + + data.reset(AppData.Reset.CACHE) + assertFalse(cache.resolve("posters").exists()) + assertFalse(root.resolve("android/cache/c").exists()) + assertTrue(root.resolve("plugins/A.jar").exists()) + + data.reset(AppData.Reset.EXTENSIONS) + assertFalse(root.resolve("plugins").exists()) + assertFalse(root.resolve("android").exists()) + assertTrue(root.resolve("settings.json").exists()) + + data.reset(AppData.Reset.EVERYTHING) + assertFalse(data.hasData()) + } + + @Test + fun scheduledResetRunsOnTheNextStartOnly() { + fillData() + val data = AppData(root, cache, installedExecutable = null) + data.scheduleReset(AppData.Reset.EVERYTHING) + assertTrue(root.resolve("settings.json").exists()) + + data.runPendingReset() + assertFalse(root.resolve("settings.json").exists()) + file(root.resolve("settings.json")) + data.runPendingReset() + assertTrue(root.resolve("settings.json").exists(), "A reset runs once") + } + + @Test + fun reinstallIsNoticedButAnUpdateThroughTheAppIsNot() { + fillData() + val exe = file(dir.resolve("install1/CloudStream.exe")) + val first = AppData(root, cache, exe) + assertFalse(first.isReinstallWithOldData(), "The first run only notes the install") + first.rememberInstall() + assertFalse(AppData(root, cache, exe).isReinstallWithOldData(), "Same install") + + // Installing again creates the executable again, with a new creation time + Thread.sleep(20) + val reinstalled = file(dir.resolve("install2/CloudStream.exe")) + assertTrue(AppData(root, cache, reinstalled).isReinstallWithOldData()) + + first.markUpdating() + val updated = AppData(root, cache, reinstalled) + assertFalse(updated.isReinstallWithOldData(), "An update the app started") + updated.rememberInstall() + assertFalse(AppData(root, cache, reinstalled).isReinstallWithOldData()) + } + + @Test + fun reinstallWithoutDataDoesNotAsk() { + val exe = file(dir.resolve("install1/CloudStream.exe")) + AppData(root, cache, exe).rememberInstall() + Thread.sleep(20) + val reinstalled = file(dir.resolve("install2/CloudStream.exe")) + assertFalse(AppData(root, cache, reinstalled).isReinstallWithOldData()) + } + + @Test + fun versionsCompareNumberByNumber() { + assertTrue(AppVersion.isNewer("4.10.0", "4.9.2")) + assertTrue(AppVersion.isNewer("v4.8.1", "4.8.0")) + assertFalse(AppVersion.isNewer("4.8.0", "4.8.0")) + assertFalse(AppVersion.isNewer("4.7.9", "4.8")) + } + + @Test + fun releaseIsReadFromGitHub() { + val release = AppUpdater.parseRelease( + """{"tag_name":"v4.9.0","html_url":"https://github.com/r/releases/tag/v4.9.0","body":"Notes", + "assets":[{"name":"libmpv-source.zip","browser_download_url":"https://x/src.zip"}, + {"name":"CloudStream-4.9.0.msi","browser_download_url":"https://x/c.msi","digest":"sha256:abc123"}]}""" + )!! + assertEquals("4.9.0", release.version) + assertEquals("https://x/c.msi", release.installerUrl) + assertEquals("abc123", release.sha256) + assertNull(AppUpdater.parseRelease("""{"tag_name":"v1","assets":[]}"""), "No installer, no update") + } +} From e299b23606bb6f54de1617236f95823402ca7115 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 14:55:04 +0530 Subject: [PATCH 11/64] Desktop: library and resume watching - Library tab: titles in Watching, Plan to watch, On hold, Completed or Dropped, set from Add to library on a title's page. - The player saves how far the user got and starts there next time; a new source carries on from the same point. - Home shows Continue watching, which moves on to the next episode once one is nearly finished, like the Android app. - Title pages show Resume with the time, and each episode's progress or that it was watched. - Saved in the Android app's DataStoreHelper keys and JSON, with its title and episode ids, so a later backup import works either way. - Remove all extensions keeps the app's data store, which now holds the library. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 5 +- .../kotlin/com/lagradost/cloudstream4/Main.kt | 3 + .../cloudstream4/browse/DetailScreen.kt | 111 ++++++++-- .../cloudstream4/browse/HomeScreen.kt | 3 + .../cloudstream4/browse/LibraryScreen.kt | 130 ++++++++++++ .../cloudstream4/player/PlayerScreen.kt | 22 +- .../settings/AppMaintenanceSettings.kt | 4 +- shared/build.gradle.kts | 1 + .../cloudstream4/detail/DetailViewModel.kt | 103 +++++++++- .../cloudstream4/library/WatchStore.kt | 189 ++++++++++++++++++ .../com/lagradost/cloudstream4/AppData.kt | 11 +- .../library/DataStoreWatchStore.kt | 158 +++++++++++++++ .../com/lagradost/cloudstream4/AppDataTest.kt | 4 +- .../cloudstream4/PlaybackViewModelTest.kt | 68 ++++++- .../library/DataStoreWatchStoreTest.kt | 54 +++++ 15 files changed, 836 insertions(+), 30 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index c3bdeadc881..7a122ab6dac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -39,9 +39,11 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.lagradost.cloudstream4.browse.DetailScreen import com.lagradost.cloudstream4.browse.HomeScreen +import com.lagradost.cloudstream4.browse.LibraryScreen import com.lagradost.cloudstream4.browse.Message import com.lagradost.cloudstream4.detail.DetailViewModel import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.library.DataStoreWatchStore import com.lagradost.cloudstream4.player.LinksViewModel import com.lagradost.cloudstream4.player.PlayerScreen import com.lagradost.cloudstream4.browse.SearchScreen @@ -235,6 +237,7 @@ fun AppShell(navigator: AppNavigator) { Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails) Destination.Search -> SearchScreen(search, navigator::openExtensions, navigator::openDetails) + Destination.Library -> LibraryScreen(DataStoreWatchStore.instance, navigator::openDetails) else -> ComingSoon(navigator.destination) } } @@ -255,7 +258,7 @@ private fun PageContent(page: Page, navigator: AppNavigator) { } when (page) { is Page.Details -> DetailScreen( - viewModel = viewModel { DetailViewModel(api, page.url) }, + viewModel = viewModel { DetailViewModel(api, page.url, DataStoreWatchStore.instance) }, onBack = { navigator.back() }, onPlay = navigator::play, onOpen = navigator::openDetails, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index e124eb04f7f..49a0f1d4f66 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -33,6 +33,7 @@ import com.lagradost.cloudstream4.compose.LocalFocusOutlineDefault 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.android.DesktopAndroid import com.lagradost.cloudstream4.plugins.DesktopPluginManager import com.lagradost.cloudstream4.settings.AppUpdatePrompt import com.lagradost.cloudstream4.settings.autoUpdateExtensions @@ -54,6 +55,8 @@ fun main() { appData.rememberInstall() DesktopLog.install(AppDirs.logs) + // Extensions and the library keep their data in the Android data store, see DataStoreWatchStore + DesktopAndroid.init(AppDirs.root.resolve("android")) // Plugins load in the background so a slow or broken plugin never delays the window @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index ebb956eb721..cea9fa9ae6a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -19,15 +19,23 @@ 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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider 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.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 @@ -44,6 +52,8 @@ import com.lagradost.cloudstream4.detail.PlayRequest import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.arrow_back import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import com.lagradost.cloudstream4.library.PlaybackPosition +import com.lagradost.cloudstream4.library.WatchType import org.jetbrains.compose.resources.painterResource /** The details of a movie or series, with its episodes and similar titles */ @@ -80,7 +90,15 @@ private fun Details( val response = state.response ?: return val season = state.seasons.getOrNull(state.selectedSeason) LazyColumn(contentPadding = PaddingValues(bottom = 32.dp), modifier = Modifier.fillMaxSize()) { - item { Header(response, state, onPlay = { viewModel.playRequest()?.let(onPlay) }) } + item { + Header( + response = response, + state = state, + onPlay = { fromStart -> viewModel.playRequest(fromStart = fromStart)?.let(onPlay) }, + onResume = { episode -> viewModel.playRequest(episode)?.let(onPlay) }, + onBookmark = { viewModel.onAction(DetailAction.SetBookmark(it)) }, + ) + } if (state.dubs.size > 1 || state.seasons.size > 1) item { Choices(state, viewModel::onAction) @@ -95,7 +113,7 @@ private fun Details( ) } items(season.episodes) { episode -> - EpisodeRow(episode) { viewModel.playRequest(episode)?.let(onPlay) } + EpisodeRow(episode, state.progressOf(episode)) { viewModel.playRequest(episode)?.let(onPlay) } HorizontalDivider(Modifier.padding(horizontal = 24.dp)) } } @@ -113,8 +131,21 @@ private fun Details( } } +/** "12:30", or "1:02:03" from an hour on */ +fun formatTime(ms: Long): String { + val total = ms / 1000 + val (h, m, s) = Triple(total / 3600, total / 60 % 60, total % 60) + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) +} + @Composable -private fun Header(response: LoadResponse, state: DetailState, onPlay: () -> Unit) { +private fun Header( + response: LoadResponse, + state: DetailState, + onPlay: (fromStart: Boolean) -> Unit, + onResume: (com.lagradost.cloudstream3.Episode) -> Unit, + onBookmark: (WatchType) -> Unit, +) { Row(Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp)) { RemoteImage( response.posterUrl, @@ -138,13 +169,33 @@ private fun Header(response: LoadResponse, state: DetailState, onPlay: () -> Uni response.plot?.let { Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) } - when { - state.unsupported != null -> Text(state.unsupported.orEmpty(), color = MaterialTheme.colorScheme.error) - state.movieData != null -> Button(onClick = onPlay, modifier = Modifier.padding(top = 8.dp)) { - Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) - Text("Play", modifier = Modifier.padding(start = 8.dp)) + state.unsupported?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (state.unsupported == null && state.movieData == null && state.seasons.isEmpty()) { + Text("No episodes yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row( + Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val resume = state.resume + if (state.unsupported == null) when { + // Continue watching left off in an episode + resume?.episode != null -> Button(onClick = { onResume(resume.episode!!) }) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + val at = if (resume.positionMs > 0) " at ${formatTime(resume.positionMs)}" else "" + Text("${if (resume.positionMs > 0) "Resume" else "Play"} ${resume.label}$at", modifier = Modifier.padding(start = 8.dp)) + } + + state.movieData != null -> { + Button(onClick = { onPlay(false) }) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + Text(resume?.let { "Resume at ${formatTime(it.positionMs)}" } ?: "Play", modifier = Modifier.padding(start = 8.dp)) + } + if (resume != null) TextButton(onClick = { onPlay(true) }) { Text("Start over") } + } } - state.seasons.isEmpty() -> Text("No episodes yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + LibraryButton(state.bookmark, onBookmark) } } } @@ -175,8 +226,37 @@ private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { } } +/** Which library list the title is in, with a menu to change it */ @Composable -private fun EpisodeRow(episode: Episode, onClick: () -> Unit) { +private fun LibraryButton(current: WatchType, onSelect: (WatchType) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { open = true }) { + Text(if (current == WatchType.NONE) "Add to library" else current.label) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + WatchType.lists.forEach { type -> + DropdownMenuItem( + text = { Text(if (type == current) "${type.label} ✓" else type.label) }, + onClick = { + open = false + onSelect(type) + }, + ) + } + if (current != WatchType.NONE) DropdownMenuItem( + text = { Text("Remove from library") }, + onClick = { + open = false + onSelect(WatchType.NONE) + }, + ) + } + } +} + +@Composable +private fun EpisodeRow(episode: Episode, progress: PlaybackPosition?, onClick: () -> Unit) { Row( Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -190,12 +270,19 @@ private fun EpisodeRow(episode: Episode, onClick: () -> Unit) { contentAlignment = Alignment.Center, ) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) } } - Column(Modifier.weight(1f)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(DetailViewModel.episodeLabel(episode), style = MaterialTheme.typography.titleSmall) episode.description?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) } + if (progress != null && !progress.watched && progress.positionMs > 0) { + LinearProgressIndicator(progress = { progress.fraction }, modifier = Modifier.fillMaxWidth(0.5f).padding(top = 4.dp)) + } + } + when { + progress?.watched == true -> Text("Watched ✓", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + progress != null && progress.positionMs > 0 -> Text("${formatTime(progress.positionMs)} / ${formatTime(progress.durationMs)}", style = MaterialTheme.typography.bodySmall) + else -> episode.runTime?.let { Text("$it min", style = MaterialTheme.typography.bodySmall) } } - episode.runTime?.let { Text("$it min", style = MaterialTheme.typography.bodySmall) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index 606c3a067a8..d09247f702a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.library.DataStoreWatchStore import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.arrow_downward import com.lagradost.cloudstream4.home.HomeAction @@ -44,6 +45,8 @@ fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit, openDetails Column(Modifier.fillMaxSize()) { if (state.providers.isNotEmpty()) TopBar(state, viewModel::onAction) + // Above the provider's rows, so it is there even when the provider fails to load + ContinueWatchingRow(DataStoreWatchStore.instance, openDetails, Modifier.padding(top = 8.dp)) when (val status = state.status) { HomeStatus.NoProviders -> Message( diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt new file mode 100644 index 00000000000..a66492bd9f7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt @@ -0,0 +1,130 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +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.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.library.TitleHeader +import com.lagradost.cloudstream4.library.WatchStore +import com.lagradost.cloudstream4.library.WatchType + +/** The titles the user put in a library list, one list at a time, most recently changed first */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LibraryScreen(store: WatchStore, openDetails: (apiName: String, url: String) -> Unit) { + val version by store.version.collectAsState() + val bookmarks = remember(version) { store.bookmarks().sortedByDescending { it.latestUpdatedTime } } + var selected by rememberSaveable { mutableStateOf(WatchType.WATCHING) } + val shown = bookmarks.filter { it.status == selected } + + Column(Modifier.fillMaxSize()) { + Text("Library", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(start = 24.dp, top = 24.dp, end = 24.dp)) + // Product Sans draws "(5)" as a circled digit, so counts use a separator instead of parentheses + FlowRow(Modifier.padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WatchType.lists.forEach { type -> + val count = bookmarks.count { it.status == type } + FilterChip( + selected = type == selected, + onClick = { selected = type }, + label = { Text(if (count > 0) "${type.label} · $count" else type.label) }, + ) + } + } + if (shown.isEmpty()) { + Message( + "Nothing in ${selected.label} yet", + "Open a title and use Add to library to keep it here.", + ) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(150.dp), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(shown, key = { it.header.id }) { bookmark -> + TitleCard(bookmark.header, onClick = { openDetails(bookmark.header.apiName, bookmark.header.url) }) + } + } + } + } +} + +@Composable +private fun TitleCard(header: TitleHeader, modifier: Modifier = Modifier, onClick: () -> Unit) { + Column(modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick)) { + RemoteImage(header.posterUrl, header.posterHeaders, contentDescription = header.name, modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)) + Text(header.name, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 6.dp)) + Text( + listOfNotNull(header.year?.toString(), header.apiName).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The titles the user started, newest first, like the Android app's Continue watching row. Each + * shows how far its episode got; opening one goes to its page, where Resume picks it up. + */ +@Composable +fun ContinueWatchingRow(store: WatchStore, openDetails: (apiName: String, url: String) -> Unit, modifier: Modifier = Modifier) { + val version by store.version.collectAsState() + val entries = remember(version) { store.resumeEntries().sortedByDescending { it.updateTime } } + if (entries.isEmpty()) return + Column(modifier.fillMaxWidth()) { + Text("Continue watching", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) + LazyRow(contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(entries, key = { it.header.id }) { entry -> + val position = remember(version, entry) { store.position(entry.episodeId ?: entry.header.id) } + Column(Modifier.width(150.dp)) { + Box { + TitleCard(entry.header, onClick = { openDetails(entry.header.apiName, entry.header.url) }) + } + if (position != null && !position.watched) { + LinearProgressIndicator(progress = { position.fraction }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) + } + Row { + val episode = listOfNotNull(entry.season?.let { "S$it" }, entry.episode?.let { "E$it" }).joinToString(" ") + if (episode.isNotEmpty()) Text(episode, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(top = 12.dp)) + else Box(Modifier.weight(1f)) + TextButton(onClick = { store.removeResume(entry.header.id) }) { Text("Remove") } + } + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index e43a9d5e59e..f779b394bb9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme 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.getValue @@ -43,6 +44,8 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.library.saveProgress import com.lagradost.cloudstream4.browse.Message import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.arrow_back @@ -76,10 +79,19 @@ fun PlayerScreen( val currentBack by rememberUpdatedState(onBack) val currentFullscreen by rememberUpdatedState(onToggleFullscreen) + // Where playback is, in ms: it starts where the user left off, and a new source carries on from there + val tracking = viewModel.request.tracking + val playedTo = remember { longArrayOf(tracking?.startPositionMs ?: 0, 0) } + fun saveProgress(positionMs: Long = playedTo[0], durationMs: Long = playedTo[1]) { + if (tracking != null) runCatching { DataStoreWatchStore.instance.saveProgress(tracking, positionMs, durationMs) } + .onFailure { println("ERROR PlayerScreen: Could not save the playback position: $it") } + } + DisposableEffect(Unit) { onDispose { saveProgress() } } + // A new link was picked, by hand or because the last one failed val selected = state.selectedLink LaunchedEffect(selected?.url) { - if (selected != null) player.play(selected) + if (selected != null) player.play(selected, playedTo[0].takeIf { it > 0 }?.let { it / 1000.0 }) } LaunchedEffect(player) { player.events.collect { event -> @@ -87,13 +99,19 @@ fun PlayerScreen( MpvEvent.Back -> currentBack() MpvEvent.ToggleFullscreen -> currentFullscreen() is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(event.url, event.message)) - MpvEvent.Ended -> Unit + MpvEvent.Ended -> saveProgress(playedTo[1], playedTo[1]) } } } LaunchedEffect(player) { + var seconds = 0 while (true) { status = player.status() + if (status.duration > 0 && status.position > 0) { + playedTo[0] = (status.position * 1000).toLong() + playedTo[1] = (status.duration * 1000).toLong() + if (++seconds % 10 == 0) saveProgress() + } delay(1000) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt index a27a998851a..ea755a83187 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt @@ -200,7 +200,7 @@ object SettingsStorageScreen : SearchableSettings { Text( when (reset) { AppData.Reset.CACHE -> "Posters and other downloaded files are deleted and downloaded again when needed." - AppData.Reset.EXTENSIONS -> "Every installed extension and its saved data is removed. Your repositories and settings stay." + AppData.Reset.EXTENSIONS -> "Every installed extension and its files are removed. Your repositories, settings and library stay." AppData.Reset.EVERYTHING -> "Settings, repositories, extensions and everything else are deleted, as if CloudStream had just been installed." } + " CloudStream restarts to do this." ) @@ -227,7 +227,7 @@ object SettingsStorageScreen : SearchableSettings { preferenceItems = listOf( Preference.PreferenceItem.InfoPreference("CloudStream keeps its data in ${AppDirs.root} and its cache in ${AppDirs.cache}."), item(AppData.Reset.CACHE, "Posters and other files that can be downloaded again"), - item(AppData.Reset.EXTENSIONS, "Installed extensions and their data. Repositories and settings stay"), + item(AppData.Reset.EXTENSIONS, "Installed extensions and their files. Repositories, settings and the library stay"), item(AppData.Reset.EVERYTHING, "Start fresh, as if CloudStream had just been installed"), ), ), diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 4a79b62770a..6ab7286ba35 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -51,6 +51,7 @@ kotlin { jvmMain.dependencies { api(project(":androidCompat")) + implementation(libs.jackson.module.kotlin) implementation(libs.ktor.client.java) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt index d3711bb05aa..770a5aa7a89 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt @@ -16,8 +16,16 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream4.compose.ActionHandler import com.lagradost.cloudstream4.compose.DefaultStateContainer import com.lagradost.cloudstream4.compose.StateContainer +import com.lagradost.cloudstream4.library.InMemoryWatchStore +import com.lagradost.cloudstream4.library.PlaybackPosition +import com.lagradost.cloudstream4.library.PlaybackTracking +import com.lagradost.cloudstream4.library.TitleHeader +import com.lagradost.cloudstream4.library.WatchIds +import com.lagradost.cloudstream4.library.WatchStore +import com.lagradost.cloudstream4.library.WatchType import com.lagradost.cloudstream4.providers.loadSafely import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch /** What the player needs to find and play the links of one movie or episode */ @@ -27,6 +35,16 @@ data class PlayRequest( /** For example "S1 E3 · Pilot", null for a movie */ val episodeLabel: String?, val data: String, + /** Where to save progress, null when nothing is tracked */ + val tracking: PlaybackTracking? = null, +) + +/** Where Continue watching left off: an episode, or the movie when [episode] is null */ +data class ResumeTarget( + val episode: Episode?, + /** For example "S1 E3", null for a movie */ + val label: String?, + val positionMs: Long, ) /** One season of episodes, or all episodes when the provider has no seasons */ @@ -54,11 +72,23 @@ data class DetailState( val selectedSeason: Int = 0, /** Why this item cannot be played on desktop, for example a torrent */ val unsupported: String? = null, -) + /** The library list the title is in */ + val bookmark: WatchType = WatchType.NONE, + /** How far each episode, or the movie by the title's id, was watched, by its id */ + val progress: Map = emptyMap(), + /** The ids the Android app gives the episodes */ + val episodeIds: Map = emptyMap(), + val titleId: Int? = null, + val resume: ResumeTarget? = null, +) { + fun progressOf(episode: Episode): PlaybackPosition? = episodeIds[episode]?.let { progress[it] } +} sealed interface DetailAction { data class SelectSeason(val index: Int) : DetailAction data class SelectDub(val dub: DubStatus) : DetailAction + /** Puts the title in a library list, or takes it out with [WatchType.NONE] */ + data class SetBookmark(val type: WatchType) : DetailAction data object Retry : DetailAction } @@ -66,6 +96,7 @@ sealed interface DetailAction { class DetailViewModel( private val api: MainAPI, private val url: String, + private val store: WatchStore = InMemoryWatchStore(), ) : ViewModel(), StateContainer by DefaultStateContainer(DetailState()), ActionHandler { @@ -75,6 +106,8 @@ class DetailViewModel( init { load() + // Progress saved by the player shows when the user comes back + viewModelScope.launch { store.version.drop(1).collect { updateState { withWatchData(this) } } } } override fun onAction(action: DetailAction) { @@ -91,10 +124,39 @@ class DetailViewModel( } } + is DetailAction.SetBookmark -> { + val header = header() ?: return + store.setBookmark(header, action.type) + updateState { copy(bookmark = action.type) } + } + DetailAction.Retry -> load() } } + private fun header(): TitleHeader? { + val response = state.value.response ?: return null + val id = state.value.titleId ?: return null + return TitleHeader(id, api.name, response.url, response.name, response.type, response.posterUrl, response.posterHeaders, response.year, response.plot) + } + + /** The library list, progress and where to resume, read again from the store */ + private fun withWatchData(state: DetailState): DetailState { + val id = state.titleId ?: return state + val ids = state.episodeIds.values + id + val progress = ids.mapNotNull { episodeId -> store.position(episodeId)?.let { episodeId to it } }.toMap() + val resume = store.resume(id)?.let { entry -> + val episode = entry.episodeId?.let { episodeId -> state.episodeIds.entries.firstOrNull { it.value == episodeId }?.key } + val position = progress[entry.episodeId ?: id]?.takeUnless { it.watched }?.positionMs ?: 0 + when { + episode != null -> ResumeTarget(episode, episodeNumber(episode), position) + state.movieData != null && position > 0 -> ResumeTarget(null, null, position) + else -> null + } + } + return state.copy(bookmark = store.bookmark(id)?.status ?: WatchType.NONE, progress = progress, resume = resume) + } + private fun load() { loadJob?.cancel() updateState { DetailState() } @@ -115,8 +177,9 @@ class DetailViewModel( } val dubs = episodesByDub.keys.filterNotNull() val dub = dubs.firstOrNull() + val titleId = WatchIds.titleId(response, api) updateState { - DetailState( + withWatchData(DetailState( status = DetailStatus.Done, response = response, movieData = when (response) { @@ -128,15 +191,35 @@ class DetailViewModel( selectedDub = dub, seasons = groupSeasons(response, episodesByDub[dub].orEmpty()), unsupported = if (response is TorrentLoadResponse) "Torrents cannot be played on desktop yet." else null, - ) + titleId = titleId, + episodeIds = WatchIds.episodeIds(response, titleId), + )) } } - /** What to play for an episode, or for the movie when [episode] is null */ - fun playRequest(episode: Episode? = null): PlayRequest? { - val response = state.value.response ?: return null - val data = episode?.data ?: state.value.movieData ?: return null - return PlayRequest(api.name, response.name, episode?.let(::episodeLabel), data) + /** + * What to play for an episode, or for the movie when [episode] is null. It starts where it was + * left unless it was finished, and its progress is saved with the next episode to move on to. + */ + fun playRequest(episode: Episode? = null, fromStart: Boolean = false): PlayRequest? { + val current = state.value + val response = current.response ?: return null + val data = episode?.data ?: current.movieData ?: return null + val header = header() + val id = if (episode == null) current.titleId else current.episodeIds[episode] + val tracking = if (header != null && id != null) { + val ordered = current.seasons.flatMap { it.episodes } + val next = episode?.let { ordered.getOrNull(ordered.indexOf(it) + 1) } + PlaybackTracking( + header = header, + id = id, + episode = episode?.episode, + season = episode?.season, + next = next?.let { n -> current.episodeIds[n]?.let { PlaybackTracking.NextEpisode(it, n.episode, n.season) } }, + startPositionMs = if (fromStart) 0 else current.progress[id]?.takeUnless { it.watched }?.positionMs ?: 0, + ) + } else null + return PlayRequest(api.name, response.name, episode?.let(::episodeLabel), data, tracking) } companion object { @@ -155,6 +238,10 @@ class DetailViewModel( } } + /** For example "S1 E3", or "Episode" without numbers */ + fun episodeNumber(episode: Episode): String = + listOfNotNull(episode.season?.let { "S$it" }, episode.episode?.let { "E$it" }).joinToString(" ").ifEmpty { "Episode" } + /** For example "S1 E3 · Pilot" */ fun episodeLabel(episode: Episode): String { val number = listOfNotNull(episode.season?.let { "S$it" }, episode.episode?.let { "E$it" }).joinToString(" ") diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt new file mode 100644 index 00000000000..82133c3b0f5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt @@ -0,0 +1,189 @@ +package com.lagradost.cloudstream4.library + +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** The Android app's library lists, with the same ids it stores */ +enum class WatchType(val internalId: Int, val label: String) { + WATCHING(0, "Watching"), + COMPLETED(1, "Completed"), + ONHOLD(2, "On hold"), + DROPPED(3, "Dropped"), + PLANTOWATCH(4, "Plan to watch"), + NONE(5, "None"); + + companion object { + /** The lists in the order the library shows them */ + val lists = listOf(WATCHING, PLANTOWATCH, ONHOLD, COMPLETED, DROPPED) + + fun fromInternalId(id: Int?) = entries.find { it.internalId == id } ?: NONE + } +} + +/** What is needed to show a title again without loading it: in the library, and in Continue watching */ +data class TitleHeader( + val id: Int, + val apiName: String, + val url: String, + val name: String, + val type: TvType?, + val posterUrl: String?, + val posterHeaders: Map? = null, + val year: Int? = null, + val plot: String? = null, +) + +data class Bookmark( + val header: TitleHeader, + val status: WatchType, + val bookmarkedTime: Long, + val latestUpdatedTime: Long, +) + +data class PlaybackPosition(val positionMs: Long, val durationMs: Long) { + val fraction: Float get() = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) + + /** Like the Android app, past 90% counts as watched */ + val watched: Boolean get() = durationMs > 0 && positionMs * 100 / durationMs >= NEXT_EPISODE_PERCENTAGE +} + +/** Where to pick up a title: the episode last watched, or the next one once that was finished */ +data class ResumeEntry( + val header: TitleHeader, + val episodeId: Int?, + val episode: Int?, + val season: Int?, + val updateTime: Long, +) + +/** The library, playback positions and Continue watching, stored the way the Android app stores them */ +interface WatchStore { + /** Changes whenever anything is saved, so screens know to read again */ + val version: StateFlow + + fun bookmark(id: Int): Bookmark? + fun bookmarks(): List + fun setBookmark(header: TitleHeader, status: WatchType) + + fun position(id: Int): PlaybackPosition? + fun setPosition(id: Int, positionMs: Long, durationMs: Long) + + fun resume(parentId: Int): ResumeEntry? + fun resumeEntries(): List + fun setResume(entry: ResumeEntry) + fun removeResume(parentId: Int) +} + +/** Kept in memory only, for tests and previews */ +class InMemoryWatchStore(private val clock: () -> Long = { System.currentTimeMillis() }) : WatchStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow = _version.asStateFlow() + private val bookmarks = LinkedHashMap() + private val positions = HashMap() + private val resumes = LinkedHashMap() + + private fun changed() = _version.update { it + 1 } + + override fun bookmark(id: Int) = bookmarks[id] + override fun bookmarks() = bookmarks.values.toList() + override fun setBookmark(header: TitleHeader, status: WatchType) { + val now = clock() + if (status == WatchType.NONE) bookmarks.remove(header.id) + else bookmarks[header.id] = Bookmark(header, status, bookmarks[header.id]?.bookmarkedTime ?: now, now) + changed() + } + + override fun position(id: Int) = positions[id] + override fun setPosition(id: Int, positionMs: Long, durationMs: Long) { + if (durationMs < MIN_DURATION_MS) return + positions[id] = PlaybackPosition(positionMs, durationMs) + changed() + } + + override fun resume(parentId: Int) = resumes[parentId] + override fun resumeEntries() = resumes.values.toList() + override fun setResume(entry: ResumeEntry) { + resumes[entry.header.id] = entry + changed() + } + + override fun removeResume(parentId: Int) { + resumes.remove(parentId) + changed() + } +} + +/** Shorter videos are not remembered, like on Android */ +const val MIN_DURATION_MS = 30_000L + +/** From this far into an episode, Continue watching moves on to the next one */ +const val NEXT_EPISODE_PERCENTAGE = 90 + +/** The ids the Android app gives titles and episodes, so saved positions and lists mean the same on both */ +object WatchIds { + fun titleId(response: LoadResponse, api: MainAPI): Int = + response.uniqueUrl.replace(api.mainUrl, "").replace("/", "").hashCode() + + /** + * Every episode's id, worked out like ResultViewModel2: anime by dub, number and season, series by + * season and number. A movie has the title's own id. + */ + fun episodeIds(response: LoadResponse, titleId: Int): Map = when (response) { + is AnimeLoadResponse -> buildMap { + for ((dub, episodes) in response.episodes) for ((index, episode) in episodes.withIndex()) { + val number = episode.episode ?: (index + 1) + putIfAbsent(episode, titleId + number + dubIndex(dub) * 1_000_000 + (episode.season?.times(10_000) ?: 0)) + } + } + is TvSeriesLoadResponse -> buildMap { + val sorted = response.episodes.sortedBy { (it.season?.times(10_000) ?: 0) + (it.episode ?: 0) } + for ((index, episode) in sorted.withIndex()) { + val number = episode.episode ?: (index + 1) + putIfAbsent(episode, titleId + (episode.season?.times(100_000) ?: 0) + number + 1) + } + } + else -> emptyMap() + } + + private fun dubIndex(dub: DubStatus) = dub.id +} + +/** What the player needs to save progress for one movie or episode */ +data class PlaybackTracking( + val header: TitleHeader, + /** The episode's id, or the title's for a movie */ + val id: Int, + val episode: Int? = null, + val season: Int? = null, + /** Continue watching moves here when this one is finished, null for a movie or the last episode */ + val next: NextEpisode? = null, + val startPositionMs: Long = 0, +) { + data class NextEpisode(val id: Int, val episode: Int?, val season: Int?) +} + +/** + * Saves how far the user got, like the Android app's setViewPosAndResume: the position of what is + * playing, and Continue watching pointing at it, or at the next episode once it is nearly done. + * Finishing the last episode or a movie takes the title out of Continue watching. + */ +fun WatchStore.saveProgress(tracking: PlaybackTracking, positionMs: Long, durationMs: Long, now: Long = System.currentTimeMillis()) { + if (durationMs < MIN_DURATION_MS || positionMs <= 0) return + setPosition(tracking.id, positionMs, durationMs) + val finished = positionMs * 100 / durationMs >= NEXT_EPISODE_PERCENTAGE + val parentId = tracking.header.id + when { + !finished -> setResume(ResumeEntry(tracking.header, tracking.id, tracking.episode, tracking.season, now)) + tracking.next != null -> setResume(ResumeEntry(tracking.header, tracking.next.id, tracking.next.episode, tracking.next.season, now)) + else -> removeResume(parentId) + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt index 9060304bd65..899dd33aad6 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt @@ -30,7 +30,7 @@ class AppData( /** Posters and other downloaded files the app can fetch again */ CACHE("Clear the cache"), - /** Installed extensions and their data. Repositories and settings stay */ + /** Installed extensions and their files. Repositories, settings and the library stay */ EXTENSIONS("Remove all extensions"), /** Everything, as if the app had never been used */ @@ -85,7 +85,11 @@ class AppData( } Reset.EXTENSIONS -> { delete(root.resolve("plugins")) - delete(root.resolve("android")) + // The app's own data store, which holds the library, is kept like on Android + val android = root.resolve("android") + if (android.isDirectory()) android.listDirectoryEntries().filter { it.name != "shared_prefs" }.forEach(::delete) + val prefs = android.resolve("shared_prefs") + if (prefs.isDirectory()) prefs.listDirectoryEntries().filter { it.name != APP_DATA_STORE }.forEach(::delete) } Reset.EVERYTHING -> { deleteContents(cache) @@ -134,6 +138,9 @@ class AppData( companion object { private const val RETRIES = 10 + /** DataStore's PREFERENCES_NAME, the app's settings store that also holds the library */ + private const val APP_DATA_STORE = "rebuild_preference.json" + val instance: AppData by lazy { AppData(AppDirs.root, AppDirs.cache) } } } diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt new file mode 100644 index 00000000000..e55e97b754d --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt @@ -0,0 +1,158 @@ +package com.lagradost.cloudstream4.library + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * The watch data in the same keys and JSON as the Android app's DataStoreHelper, under its first + * account ("0/..."), so a later backup import can read either app's data. It lives in the same + * settings store extensions use through DataStore. + */ +class DataStoreWatchStore( + private val context: Context = DesktopAndroid.application, + private val clock: () -> Long = { System.currentTimeMillis() }, +) : WatchStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow = _version.asStateFlow() + + private fun changed() = _version.update { it + 1 } + + // The Android app's JSON shapes. Fields desktop does not use are kept out, Jackson ignores them on reading + + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class BookmarkedData( + @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, + @JsonProperty("id") val id: Int?, + @JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long, + @JsonProperty("name") val name: String, + @JsonProperty("url") val url: String, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("type") val type: TvType?, + @JsonProperty("posterUrl") val posterUrl: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("posterHeaders") val posterHeaders: Map? = null, + @JsonProperty("plot") val plot: String? = null, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class PosDur( + @JsonProperty("position") val position: Long, + @JsonProperty("duration") val duration: Long, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean = false, + ) + + /** What Continue watching shows for a title, the Android app's download header cache */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + @JsonProperty("id") val id: Int, + ) + + private fun BookmarkedData.toHeader(id: Int) = TitleHeader(id, apiName, url, name, type, posterUrl, posterHeaders, year, plot) + + override fun bookmark(id: Int): Bookmark? = synchronized(this) { + val status = WatchType.fromInternalId(context.getKey("$ACCOUNT/$WATCH_STATE/$id", Int::class.javaObjectType)) + if (status == WatchType.NONE) return null + val data = context.getKey("$ACCOUNT/$WATCH_STATE_DATA/$id", BookmarkedData::class.java) ?: return null + Bookmark(data.toHeader(id), status, data.bookmarkedTime, data.latestUpdatedTime) + } + + override fun bookmarks(): List = synchronized(this) { + context.getKeys("$ACCOUNT/$WATCH_STATE_DATA").mapNotNull { it.substringAfterLast('/').toIntOrNull()?.let(::bookmark) } + } + + override fun setBookmark(header: TitleHeader, status: WatchType) { + synchronized(this) { + val id = header.id + if (status == WatchType.NONE) { + context.removeKey("$ACCOUNT/$WATCH_STATE/$id") + context.removeKey("$ACCOUNT/$WATCH_STATE_DATA/$id") + } else { + val now = clock() + val since = context.getKey("$ACCOUNT/$WATCH_STATE_DATA/$id", BookmarkedData::class.java)?.bookmarkedTime ?: now + context.setKey("$ACCOUNT/$WATCH_STATE/$id", status.internalId) + context.setKey( + "$ACCOUNT/$WATCH_STATE_DATA/$id", + BookmarkedData(since, id, now, header.name, header.url, header.apiName, header.type, header.posterUrl, header.year, header.posterHeaders, header.plot), + ) + } + } + changed() + } + + override fun position(id: Int): PlaybackPosition? = + context.getKey("$ACCOUNT/$POS_DUR/$id", PosDur::class.java)?.let { PlaybackPosition(it.position, it.duration) } + + override fun setPosition(id: Int, positionMs: Long, durationMs: Long) { + if (durationMs < MIN_DURATION_MS) return + context.setKey("$ACCOUNT/$POS_DUR/$id", PosDur(positionMs, durationMs)) + changed() + } + + override fun resume(parentId: Int): ResumeEntry? = synchronized(this) { + val resume = context.getKey("$ACCOUNT/$RESUME_WATCHING/$parentId", ResumeWatching::class.java) ?: return null + val header = context.getKey("$HEADER_CACHE/$parentId", DownloadHeaderCached::class.java) ?: return null + ResumeEntry( + TitleHeader(parentId, header.apiName, header.url, header.name, header.type, header.poster), + resume.episodeId, resume.episode, resume.season, resume.updateTime, + ) + } + + override fun resumeEntries(): List = synchronized(this) { + context.getKeys("$ACCOUNT/$RESUME_WATCHING").mapNotNull { it.substringAfterLast('/').toIntOrNull()?.let(::resume) } + } + + override fun setResume(entry: ResumeEntry) { + synchronized(this) { + val header = entry.header + context.setKey("$ACCOUNT/$RESUME_WATCHING/${header.id}", ResumeWatching(header.id, entry.episodeId, entry.episode, entry.season, entry.updateTime)) + context.setKey( + "$HEADER_CACHE/${header.id}", + DownloadHeaderCached(header.apiName, header.url, header.type ?: TvType.Movie, header.name, header.posterUrl, clock(), header.id), + ) + } + changed() + } + + override fun removeResume(parentId: Int) { + context.removeKey("$ACCOUNT/$RESUME_WATCHING/$parentId") + changed() + } + + companion object { + /** The Android app's first account, DataStoreHelper.currentAccount */ + private const val ACCOUNT = "0" + private const val WATCH_STATE = "result_watch_state" + private const val WATCH_STATE_DATA = "result_watch_state_data" + private const val POS_DUR = "video_pos_dur" + private const val RESUME_WATCHING = "result_resume_watching_2" + private const val HEADER_CACHE = "download_header_cache" + + val instance: DataStoreWatchStore by lazy { DataStoreWatchStore() } + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt index 4f69cc9cffc..986b1cb9de6 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt @@ -29,6 +29,7 @@ class AppDataTest { file(root.resolve("settings.json")) file(root.resolve("plugins/A.jar")) file(root.resolve("android/shared_prefs/a.json")) + file(root.resolve("android/shared_prefs/rebuild_preference.json")) file(root.resolve("android/cache/c")) file(cache.resolve("posters/p.jpg")) } @@ -45,7 +46,8 @@ class AppDataTest { data.reset(AppData.Reset.EXTENSIONS) assertFalse(root.resolve("plugins").exists()) - assertFalse(root.resolve("android").exists()) + assertFalse(root.resolve("android/shared_prefs/a.json").exists()) + assertTrue(root.resolve("android/shared_prefs/rebuild_preference.json").exists(), "The library stays") assertTrue(root.resolve("settings.json").exists()) data.reset(AppData.Reset.EVERYTHING) diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt index 9292ac277b3..250791848d6 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt @@ -22,6 +22,9 @@ import com.lagradost.cloudstream4.detail.DetailAction import com.lagradost.cloudstream4.detail.DetailStatus import com.lagradost.cloudstream4.detail.DetailViewModel import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.library.InMemoryWatchStore +import com.lagradost.cloudstream4.library.WatchType +import com.lagradost.cloudstream4.library.saveProgress import com.lagradost.cloudstream4.player.LinksAction import com.lagradost.cloudstream4.player.LinksViewModel import kotlinx.coroutines.CompletableDeferred @@ -110,7 +113,7 @@ class PlaybackViewModelTest { assertEquals("movie-data", state.movieData) assertEquals(listOf("Drama"), state.response!!.tags) assertTrue(state.seasons.isEmpty()) - assertEquals(PlayRequest("Catalog", "A movie", null, "movie-data"), vm.playRequest()) + assertEquals(PlayRequest("Catalog", "A movie", null, "movie-data"), vm.playRequest()!!.copy(tracking = null)) } @Test @@ -121,7 +124,7 @@ class PlaybackViewModelTest { assertEquals(listOf("s1e1", "s1e2"), state.seasons[0].episodes.map { it.data }) val pilot = state.seasons[0].episodes[0] - assertEquals(PlayRequest("Catalog", "A series", "S1 E1 · Pilot", "s1e1"), vm.playRequest(pilot)) + assertEquals(PlayRequest("Catalog", "A series", "S1 E1 · Pilot", "s1e1"), vm.playRequest(pilot)!!.copy(tracking = null)) vm.onAction(DetailAction.SelectSeason(1)) assertEquals(1, vm.state.value.selectedSeason) @@ -215,4 +218,65 @@ class PlaybackViewModelTest { assertNull(state.selected) assertTrue(state.exhausted) } + + @Test + fun episodeIdsAreTheAndroidAppsIds() { + val vm = DetailViewModel(api, "https://example.invalid/series") + val state = await(vm.state) { it.status == DetailStatus.Done } + // The url without the provider's address and slashes, hashed, like ResultViewModel2.getId + val titleId = "series".hashCode() + assertEquals(titleId, state.titleId) + // Series episodes sort by season then number: extra, S1 E1, S1 E2, S2 E1 + val pilot = state.seasons[0].episodes[0] + assertEquals(titleId + 100_000 + 1 + 1, state.episodeIds[pilot]) + + val anime = await(DetailViewModel(api, "https://example.invalid/anime").state) { it.status == DetailStatus.Done } + val dubbed = anime.episodeIds.entries.single { it.key.data == "dub1" }.value + assertEquals("anime".hashCode() + 1 + 1_000_000, dubbed) + } + + @Test + fun libraryAndResumeFollowWhatWasWatched() { + val store = InMemoryWatchStore() + val vm = DetailViewModel(api, "https://example.invalid/series", store) + val state = await(vm.state) { it.status == DetailStatus.Done } + val (pilot, second) = state.seasons[0].episodes + + vm.onAction(DetailAction.SetBookmark(WatchType.WATCHING)) + assertEquals(WatchType.WATCHING, vm.state.value.bookmark) + assertEquals("A series", store.bookmark(state.titleId!!)!!.header.name) + + // Halfway through the pilot: Resume picks it up there + val tracking = vm.playRequest(pilot)!!.tracking!! + assertEquals(state.episodeIds[second], tracking.next!!.id) + store.saveProgress(tracking, positionMs = 600_000, durationMs = 1_200_000) + await(vm.state) { it.resume?.episode == pilot } + assertEquals(600_000, vm.state.value.resume!!.positionMs) + assertEquals(0.5f, vm.state.value.progressOf(pilot)!!.fraction) + assertEquals(600_000, vm.playRequest(pilot)!!.tracking!!.startPositionMs) + + // Nearly finished: Continue watching moves on to the next episode, which starts at the beginning + store.saveProgress(tracking, positionMs = 1_150_000, durationMs = 1_200_000) + await(vm.state) { it.resume?.episode == second } + assertTrue(vm.state.value.progressOf(pilot)!!.watched) + assertEquals(0, vm.playRequest(pilot)!!.tracking!!.startPositionMs, "A finished episode starts again from the beginning") + + // Finishing the last episode takes the title out of Continue watching + val extra = vm.playRequest(state.seasons[2].episodes.single())!!.tracking!! + assertNull(extra.next) + store.saveProgress(extra, positionMs = 1_190_000, durationMs = 1_200_000) + assertNull(store.resume(state.titleId!!)) + + vm.onAction(DetailAction.SetBookmark(WatchType.NONE)) + assertNull(store.bookmark(state.titleId!!)) + } + + @Test + fun shortVideosAreNotRemembered() { + val store = InMemoryWatchStore() + val vm = DetailViewModel(api, "https://example.invalid/movie", store) + await(vm.state) { it.status == DetailStatus.Done } + store.saveProgress(vm.playRequest()!!.tracking!!, positionMs = 10_000, durationMs = 20_000) + assertTrue(store.resumeEntries().isEmpty()) + } } diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt new file mode 100644 index 00000000000..bb757d5044f --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt @@ -0,0 +1,54 @@ +package com.lagradost.cloudstream4.library + +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DataStoreWatchStoreTest { + private val dir = Files.createTempDirectory("cs-watch") + + init { + DesktopAndroid.init(dir) + } + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + private val header = TitleHeader(42, "Catalog", "https://example.invalid/series", "A series", TvType.TvSeries, "https://example.invalid/p.jpg", year = 2024) + + @Test + fun savesInTheAndroidAppsKeysAndReadsBack() { + val store = DataStoreWatchStore(clock = { 1_000 }) + store.setBookmark(header, WatchType.PLANTOWATCH) + store.setPosition(4243, 60_000, 1_200_000) + store.setResume(ResumeEntry(header, 4243, 1, 1, 2_000)) + + // A new store reads what the last one wrote, as after a restart + val again = DataStoreWatchStore() + assertEquals(WatchType.PLANTOWATCH, again.bookmark(42)!!.status) + assertEquals(header, again.bookmark(42)!!.header) + assertEquals(PlaybackPosition(60_000, 1_200_000), again.position(4243)) + // Continue watching keeps only what the Android app's header cache has + assertEquals(listOf(ResumeEntry(header.copy(year = null), 4243, 1, 1, 2_000)), again.resumeEntries()) + + // Writes go to the file in the background, wait for them + val file = dir.resolve("shared_prefs/rebuild_preference.json") + val keys = listOf("0/result_watch_state/42", "0/result_watch_state_data/42", "0/video_pos_dur/4243", "0/result_resume_watching_2/42", "download_header_cache/42") + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline && !(Files.exists(file) && keys.all { it in file.readText() })) Thread.sleep(50) + keys.forEach { assertTrue(it in file.readText(), "$it is saved under the Android app's key") } + + again.setBookmark(header, WatchType.NONE) + again.removeResume(42) + assertNull(again.bookmark(42)) + assertTrue(again.resumeEntries().isEmpty()) + } +} From 4a0aa22e9f2e300afa757021dd9f56a44ada4447 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 15:14:58 +0530 Subject: [PATCH 12/64] Desktop: search history and extension toasts Recent searches on the Search page, stored in the Android app's search_history keys. Extension toasts (android.widget.Toast and CommonActivity.showToast) show as a message in the app window. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../jvmMain/kotlin/android/widget/Toast.kt | 53 ++++++++++++++ .../cloudstream4/android/DesktopAndroid.kt | 5 ++ .../com/lagradost/cloudstream4/AppShell.kt | 28 +++++++- .../cloudstream4/browse/SearchScreen.kt | 53 ++++++++++++++ .../drawable/history_toggle_off_24px.xml | 9 +++ .../cloudstream4/search/SearchHistory.kt | 59 ++++++++++++++++ .../cloudstream4/search/SearchViewModel.kt | 25 ++++++- .../search/DataStoreSearchHistory.kt | 70 +++++++++++++++++++ .../cloudstream4/BrowseViewModelTest.kt | 29 ++++++++ .../search/DataStoreSearchHistoryTest.kt | 63 +++++++++++++++++ 10 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 androidCompat/src/jvmMain/kotlin/android/widget/Toast.kt create mode 100644 shared/src/commonMain/composeResources/drawable/history_toggle_off_24px.xml create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchHistory.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistory.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistoryTest.kt diff --git a/androidCompat/src/jvmMain/kotlin/android/widget/Toast.kt b/androidCompat/src/jvmMain/kotlin/android/widget/Toast.kt new file mode 100644 index 00000000000..3f10313402e --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/android/widget/Toast.kt @@ -0,0 +1,53 @@ +package android.widget + +import android.content.Context +import com.lagradost.cloudstream4.android.DesktopAndroid + +/** Android's Toast, shown as a message at the bottom of the app window */ +open class Toast(private val context: Context?) { + private var text: CharSequence? = null + private var length = LENGTH_SHORT + + open fun show() { + text?.toString()?.takeIf { it.isNotBlank() }?.let(DesktopAndroid::showToast) + } + + open fun cancel() {} + + open fun setText(s: CharSequence?) { + text = s + } + + open fun setText(resId: Int) { + text = context?.getString(resId) + } + + open fun getDuration(): Int = length + + open fun setDuration(duration: Int) { + length = duration + } + + open fun setGravity(gravity: Int, xOffset: Int, yOffset: Int) {} + + open fun setMargin(horizontalMargin: Float, verticalMargin: Float) {} + + companion object { + const val LENGTH_SHORT = 0 + const val LENGTH_LONG = 1 + + @JvmStatic + fun makeText(context: Context?, text: CharSequence?, duration: Int): Toast = + Toast(context).apply { + setText(text) + setDuration(duration) + } + + @JvmStatic + fun makeText(context: Context?, resId: Int, duration: Int): Toast = + Toast(context).apply { + setText(resId) + setDuration(duration) + } + } +} diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt index cea3c3a18e9..1a092aca9c3 100644 --- a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt @@ -21,7 +21,10 @@ object DesktopAndroid { /** Sets the folder extensions keep their files and settings in. Call before loading extensions */ fun init(dataDir: Path) { + if (dataDir == root) return root = dataDir + // Settings already opened belong to the old folder + preferences.clear() } val filesDir: Path get() = root.resolve("files").also { it.createDirectories() } @@ -42,6 +45,8 @@ object DesktopAndroid { var toastHandler: ((String) -> Unit)? = null fun showToast(message: String) { + // Android shows nothing for an empty toast + if (message.isBlank()) return println("INFO Extension toast: $message") toastHandler?.invoke(message) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 7a122ab6dac..e010af75786 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -11,10 +11,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -37,6 +40,7 @@ import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel +import com.lagradost.cloudstream4.android.DesktopAndroid import com.lagradost.cloudstream4.browse.DetailScreen import com.lagradost.cloudstream4.browse.HomeScreen import com.lagradost.cloudstream4.browse.LibraryScreen @@ -50,10 +54,14 @@ import com.lagradost.cloudstream4.browse.SearchScreen import com.lagradost.cloudstream4.home.HomeAction import com.lagradost.cloudstream4.home.HomeViewModel import com.lagradost.cloudstream4.plugins.DesktopPluginManager +import com.lagradost.cloudstream4.search.DataStoreSearchHistory import com.lagradost.cloudstream4.search.SearchViewModel import com.lagradost.cloudstream4.settings.ExtensionsScreen import com.lagradost.cloudstream4.settings.SettingsHomeScreen +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.consumeAsFlow import com.mihon.presentation.LocalBackPress import com.mihon.presentation.settings.SearchableSettings import org.jetbrains.compose.resources.DrawableResource @@ -182,7 +190,7 @@ fun AppShell(navigator: AppNavigator) { val settings = rememberAppSettings() // Kept for the whole session, so switching destination keeps the results val home = remember { HomeViewModel(::loadedProviders, settings) } - val search = remember { SearchViewModel(::loadedProviders, settings) } + val search = remember { SearchViewModel(::loadedProviders, settings, DataStoreSearchHistory.instance) } // Plugins load in the background after the window opens, and the provider settings can change at any time LaunchedEffect(home) { combine( @@ -192,6 +200,9 @@ fun AppShell(navigator: AppNavigator) { ) { _, _, _ -> }.collect { home.onAction(HomeAction.ProvidersChanged) } } + val toasts = remember { SnackbarHostState() } + ExtensionToasts(toasts) + val page = navigator.pages.lastOrNull() val hideRail = page is Page.Player && navigator.fullscreen Row(Modifier.fillMaxSize()) { @@ -240,10 +251,25 @@ fun AppShell(navigator: AppNavigator) { Destination.Library -> LibraryScreen(DataStoreWatchStore.instance, navigator::openDetails) else -> ComingSoon(navigator.destination) } + SnackbarHost(toasts, Modifier.align(Alignment.BottomCenter).padding(16.dp)) } } } +/** + * Shows the toasts extensions raise, one after another. They can come from any thread, so they are + * queued, and when many arrive at once the oldest are dropped. + */ +@Composable +private fun ExtensionToasts(host: SnackbarHostState) { + val queue = remember { Channel(capacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) } + DisposableEffect(queue) { + DesktopAndroid.toastHandler = { queue.trySend(it) } + onDispose { DesktopAndroid.toastHandler = null } + } + LaunchedEffect(queue) { queue.consumeAsFlow().collect { host.showSnackbar(it) } } +} + @Composable private fun PageContent(page: Page, navigator: AppNavigator) { val apiName = when (page) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt index 4b7e9f0bc7b..e2678c51b49 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -1,6 +1,8 @@ package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -14,11 +16,13 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -27,13 +31,16 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.close +import com.lagradost.cloudstream4.generated.resources.history_toggle_off_24px import com.lagradost.cloudstream4.generated.resources.search_icon import com.lagradost.cloudstream4.generated.resources.title_search import com.lagradost.cloudstream4.search.ProviderStatus import com.lagradost.cloudstream4.search.SearchAction +import com.lagradost.cloudstream4.search.SearchHistoryEntry import com.lagradost.cloudstream4.search.SearchViewModel import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -85,6 +92,13 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDet "Install an extension, or check the extension languages and media types under Settings > Providers.", ) { Button(onClick = openExtensions) { Text("Open Extensions") } } + state.searchedQuery.isEmpty() && state.history.isNotEmpty() -> SearchHistory( + entries = state.history, + onSearch = { viewModel.onAction(SearchAction.SearchFor(it.query)) }, + onRemove = { viewModel.onAction(SearchAction.RemoveHistory(it.key)) }, + onClear = { viewModel.onAction(SearchAction.ClearHistory) }, + ) + state.searchedQuery.isEmpty() -> Message("Search all your extensions", "Type a title and press Enter.") found.isEmpty() && !state.isSearching -> Message( @@ -122,3 +136,42 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDet } } } + +/** Past searches, newest first, like the Android app's search history */ +@Composable +private fun SearchHistory( + entries: List, + onSearch: (SearchHistoryEntry) -> Unit, + onRemove: (SearchHistoryEntry) -> Unit, + onClear: () -> Unit, +) { + LazyColumn(contentPadding = PaddingValues(bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + item { + Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Recent searches", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + TextButton(onClick = onClear) { Text("Clear all") } + } + } + items(entries, key = { it.key }) { entry -> + Row( + Modifier.fillMaxWidth().clickable { onSearch(entry) }.padding(start = 24.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(Res.drawable.history_toggle_off_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + entry.query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + ) + IconButton(onClick = { onRemove(entry) }) { + Icon(painterResource(Res.drawable.close), contentDescription = "Remove from history") + } + } + } + } +} diff --git a/shared/src/commonMain/composeResources/drawable/history_toggle_off_24px.xml b/shared/src/commonMain/composeResources/drawable/history_toggle_off_24px.xml new file mode 100644 index 00000000000..18a2eaba156 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/history_toggle_off_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchHistory.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchHistory.kt new file mode 100644 index 00000000000..8d3a215f3b8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchHistory.kt @@ -0,0 +1,59 @@ +package com.lagradost.cloudstream4.search + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** A past search, keyed like the Android app: the query's hash, so searching it again moves it to the top */ +data class SearchHistoryEntry( + val key: String, + val query: String, + val searchedAt: Long, +) + +/** The searches the user ran, newest first */ +interface SearchHistoryStore { + /** Increases on every change, so screens know to read again */ + val version: StateFlow + + fun entries(): List + fun add(query: String) + fun remove(key: String) + fun clear() + + companion object { + /** The Android app's key for a query */ + fun keyOf(query: String) = query.hashCode().toString() + + /** The Android app saves searches of two or more letters */ + fun isSaved(query: String) = query.length > 1 + } +} + +class InMemorySearchHistory(private val clock: () -> Long = { System.currentTimeMillis() }) : SearchHistoryStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow = _version.asStateFlow() + private val entries = LinkedHashMap() + + private fun changed() = _version.update { it + 1 } + + override fun entries() = entries.values.sortedByDescending { it.searchedAt } + + override fun add(query: String) { + if (!SearchHistoryStore.isSaved(query)) return + val key = SearchHistoryStore.keyOf(query) + entries[key] = SearchHistoryEntry(key, query, clock()) + changed() + } + + override fun remove(key: String) { + entries.remove(key) + changed() + } + + override fun clear() { + entries.clear() + changed() + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt index d98f9acf492..236f22202a2 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/search/SearchViewModel.kt @@ -14,6 +14,7 @@ import com.lagradost.cloudstream4.providers.searchSafely import com.lagradost.cloudstream4.providers.withoutQualities import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.job import kotlinx.coroutines.launch @@ -40,6 +41,8 @@ data class SearchState( val results: List = emptyList(), /** True when a search ran but no provider was enabled to search with */ val noProviders: Boolean = false, + /** Past searches, newest first */ + val history: List = emptyList(), ) { val isSearching: Boolean get() = results.any { it.status == ProviderStatus.Loading } } @@ -51,6 +54,10 @@ sealed interface SearchAction { /** Fetches the next page from one provider */ data class LoadMore(val apiName: String) : SearchAction data object Clear : SearchAction + /** Searches again for a past query */ + data class SearchFor(val query: String) : SearchAction + data class RemoveHistory(val key: String) : SearchAction + data object ClearHistory : SearchAction } /** @@ -62,9 +69,14 @@ sealed interface SearchAction { class SearchViewModel( private val apis: () -> List, private val settings: AppSettings, -) : ViewModel(), StateContainer by DefaultStateContainer(SearchState()), + private val history: SearchHistoryStore = InMemorySearchHistory(), +) : ViewModel(), StateContainer by DefaultStateContainer(SearchState(history = history.entries())), ActionHandler { + init { + viewModelScope.launch { history.version.drop(1).collect { updateState { copy(history = this@SearchViewModel.history.entries()) } } } + } + /** Parent of every request for the current query, cancelled when a new search starts */ private var searchJob: Job = SupervisorJob() /** Increases with every search, so a late answer for an old query is dropped */ @@ -78,8 +90,16 @@ class SearchViewModel( is SearchAction.LoadMore -> loadMore(action.apiName) SearchAction.Clear -> { cancel() - updateState { SearchState() } + updateState { SearchState(history = history) } } + + is SearchAction.SearchFor -> { + updateState { copy(query = action.query) } + search(action.query.trim()) + } + + is SearchAction.RemoveHistory -> history.remove(action.key) + SearchAction.ClearHistory -> history.clear() } } @@ -94,6 +114,7 @@ class SearchViewModel( updateState { copy(searchedQuery = "", results = emptyList(), noProviders = false) } return } + history.add(query) val provider = settings.provider searched = filterProviders( apis(), diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistory.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistory.kt new file mode 100644 index 00000000000..f82c9603525 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistory.kt @@ -0,0 +1,70 @@ +package com.lagradost.cloudstream4.search + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Search history in the Android app's key and JSON, "0/search_history/", next to + * the library in the same store. + */ +class DataStoreSearchHistory( + private val context: Context = DesktopAndroid.application, + private val clock: () -> Long = { System.currentTimeMillis() }, +) : SearchHistoryStore { + private val _version = MutableStateFlow(0) + override val version: StateFlow = _version.asStateFlow() + + private fun changed() = _version.update { it + 1 } + + /** The Android app's SearchHistoryItem */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class SearchHistoryItem( + @JsonProperty("searchedAt") val searchedAt: Long, + @JsonProperty("searchText") val searchText: String, + @JsonProperty("type") val type: List = emptyList(), + @JsonProperty("key") val key: String, + ) + + override fun entries(): List = synchronized(this) { + context.getKeys("$ACCOUNT/$SEARCH_HISTORY") + .mapNotNull { context.getKey(it, SearchHistoryItem::class.java) } + .map { SearchHistoryEntry(it.key, it.searchText, it.searchedAt) } + .sortedByDescending { it.searchedAt } + } + + override fun add(query: String) { + if (!SearchHistoryStore.isSaved(query)) return + val key = SearchHistoryStore.keyOf(query) + synchronized(this) { context.setKey("$ACCOUNT/$SEARCH_HISTORY/$key", SearchHistoryItem(clock(), query, emptyList(), key)) } + changed() + } + + override fun remove(key: String) { + synchronized(this) { context.removeKey("$ACCOUNT/$SEARCH_HISTORY/$key") } + changed() + } + + override fun clear() { + synchronized(this) { context.getKeys("$ACCOUNT/$SEARCH_HISTORY").forEach { context.removeKey(it) } } + changed() + } + + companion object { + /** The Android app's first account, DataStoreHelper.currentAccount */ + private const val ACCOUNT = "0" + private const val SEARCH_HISTORY = "search_history" + + val instance: DataStoreSearchHistory by lazy { DataStoreSearchHistory() } + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt index 62dae2d1bf3..0ab9a5cc76b 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/BrowseViewModelTest.kt @@ -18,6 +18,7 @@ import com.lagradost.cloudstream4.home.HomeViewModel import com.lagradost.cloudstream4.plugins.DesktopPluginManager import com.lagradost.cloudstream4.plugins.RepositoryClient import com.lagradost.cloudstream4.providers.filterProviders +import com.lagradost.cloudstream4.search.InMemorySearchHistory import com.lagradost.cloudstream4.search.ProviderStatus import com.lagradost.cloudstream4.search.SearchAction import com.lagradost.cloudstream4.search.SearchViewModel @@ -164,6 +165,34 @@ class BrowseViewModelTest { assertEquals(listOf("Slow new 1", "Slow cam 1"), state.results.single().items.map { it.name }) } + @Test + fun searchesAreRememberedNewestFirstAndCanBeRunAgain() { + var now = 0L + val history = InMemorySearchHistory { ++now } + val provider = FakeProvider("P") + val vm = SearchViewModel({ listOf(provider) }, settings, history) + + listOf("dune", "x", "alien", "dune").forEach { + vm.onAction(SearchAction.QueryChanged(it)) + vm.onAction(SearchAction.Submit) + } + // One letter is not saved, and searching "dune" again moves it to the top + assertEquals(listOf("dune", "alien"), vm.state.value.history.map { it.query }) + + vm.onAction(SearchAction.Clear) + assertEquals(listOf("dune", "alien"), vm.state.value.history.map { it.query }, "clearing the box keeps the history") + + vm.onAction(SearchAction.SearchFor("alien")) + val state = awaitState(vm.state) { !it.isSearching && it.searchedQuery == "alien" } + assertEquals("alien", state.query) + assertEquals(listOf("alien", "dune"), state.history.map { it.query }) + + vm.onAction(SearchAction.RemoveHistory(state.history.first().key)) + assertEquals(listOf("dune"), vm.state.value.history.map { it.query }) + vm.onAction(SearchAction.ClearHistory) + assertTrue(vm.state.value.history.isEmpty()) + } + @Test fun searchWithNoEnabledProvidersSaysSo() { settings.provider.extensionLanguages.set(setOf("fr")) diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistoryTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistoryTest.kt new file mode 100644 index 00000000000..8e9d7178228 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/search/DataStoreSearchHistoryTest.kt @@ -0,0 +1,63 @@ +package com.lagradost.cloudstream4.search + +import android.widget.Toast +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DataStoreSearchHistoryTest { + private val dir = Files.createTempDirectory("cs-search") + + init { + DesktopAndroid.init(dir) + } + + @AfterTest + fun cleanup() { + DesktopAndroid.toastHandler = null + dir.toFile().deleteRecursively() + } + + @Test + fun savesInTheAndroidAppsKeyAndReadsBack() { + var now = 0L + val history = DataStoreSearchHistory(clock = { ++now }) + history.add("dune") + history.add("alien") + history.add("d") + + // A new store reads what the last one wrote, as after a restart + val again = DataStoreSearchHistory() + assertEquals(listOf("alien", "dune"), again.entries().map { it.query }) + assertEquals("dune".hashCode().toString(), again.entries().last().key) + + val file = dir.resolve("shared_prefs/rebuild_preference.json") + val key = "0/search_history/${"dune".hashCode()}" + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline && !(Files.exists(file) && key in file.readText())) Thread.sleep(50) + assertTrue(key in file.readText(), "saved under the Android app's key") + + again.remove("dune".hashCode().toString()) + assertEquals(listOf("alien"), again.entries().map { it.query }) + again.clear() + assertTrue(again.entries().isEmpty()) + } + + @Test + fun extensionToastsReachTheApp() { + val shown = mutableListOf() + DesktopAndroid.toastHandler = { shown += it } + + Toast.makeText(DesktopAndroid.application, "Logged in", Toast.LENGTH_LONG).show() + CommonActivity.showToast("Cookies saved") + // Android shows nothing for an empty toast + Toast.makeText(DesktopAndroid.application, "", Toast.LENGTH_SHORT).show() + + assertEquals(listOf("Logged in", "Cookies saved"), shown) + } +} From 22ccb03cd91e26e8d99ece925d4f64562a6a8000 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 17:09:29 +0530 Subject: [PATCH 13/64] Desktop: easier player controls, panels and subtitles - A control bar under the video: play/pause, -10/+10 s, a seek bar with the time, mute, volume and full screen. In full screen the bars show while the mouse moves and hide after 3 s. mpv's own controller is off: embedded, mpv gets no mouse input, so it never worked. - The mouse over the video is handled on the canvas: a click closes an open panel or pauses, a double click toggles full screen, the wheel changes the volume. Escape closes an open panel first. - Source names show in full, in a wider panel. - Subtitles: the picked subtitle is added again when the source changes, extension subtitles are matched to mpv tracks by file, sorted by language, numbered when a language repeats and show their host. A Timing control moves them earlier or later. New Speed panel. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 130 ++++++-- .../cloudstream4/player/PlayerScreen.kt | 308 +++++++++++++++--- .../drawable/baseline_fullscreen_24.xml | 9 + .../drawable/baseline_fullscreen_exit_24.xml | 9 + .../drawable/ic_baseline_volume_mute_24.xml | 4 + .../drawable/ic_baseline_volume_up_24.xml | 4 + 6 files changed, 398 insertions(+), 66 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/baseline_fullscreen_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/baseline_fullscreen_exit_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_volume_mute_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_volume_up_24.xml diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index c9148f0f131..9607e1db36c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -10,6 +10,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow import java.awt.Canvas import java.awt.Color +import java.awt.Toolkit +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseWheelEvent import java.io.File import java.util.concurrent.TimeUnit import java.util.concurrent.CountDownLatch @@ -68,6 +72,8 @@ data class MpvTrack( val lang: String?, val selected: Boolean, val external: Boolean, + /** The file an external track was added from */ + val externalFilename: String? = null, ) { val label: String get() = listOfNotNull(title, lang).distinct().joinToString(" · ").ifEmpty { "Track $id" } } @@ -78,13 +84,22 @@ data class MpvStatus( val paused: Boolean = false, val buffering: Boolean = false, val tracks: List = emptyList(), + /** 0 to 100 */ + val volume: Int = 100, + val muted: Boolean = false, + val speed: Double = 1.0, + /** Seconds the subtitles are shown later, negative for earlier */ + val subDelay: Double = 0.0, ) +/** A subtitle file to show with a link, added again whenever the link changes */ +data class MpvSubtitle(val file: File, val title: String, val select: Boolean) + sealed interface MpvEvent { - /** Escape or Backspace was pressed inside the video */ - data object Back : MpvEvent /** F or a double click inside the video */ data object ToggleFullscreen : MpvEvent + /** A single click inside the video */ + data object Click : MpvEvent data object Ended : MpvEvent /** The file could not be played, for example a refused or broken link */ data class Failed(val url: String, val message: String) : MpvEvent @@ -93,7 +108,7 @@ sealed interface MpvEvent { /** * One mpv player drawing into [canvas]. It starts when the canvas is shown and shuts down when the * canvas is removed, before its window is destroyed. Calls made before it started are run then. - * mpv's own on-screen controller gives the seek bar and buttons, and its default keys work. + * The app draws the controls; the mouse over the video is handled here and reported as [events]. */ class MpvPlayer internal constructor(private val lib: MpvLibrary) { private val ctx: Pointer = lib.mpv_create() ?: error("mpv_create failed") @@ -104,10 +119,21 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { private val stopped = CountDownLatch(1) /** The url being played, so a failure can be reported for the right link */ @Volatile private var currentUrl: String? = null + /** Whether the current file finished loading. Subtitles can only be added to a loaded file */ + private var fileLoaded = false + private val afterLoad = mutableListOf<() -> Unit>() private val _events = Channel(Channel.UNLIMITED) val events: Flow = _events.receiveAsFlow() + /** Called on every mouse move over the video, from the AWT thread */ + @Volatile var onMouseMove: (() -> Unit)? = null + + // A double click toggles full screen, so a single click waits a moment to be sure it is not one + private val singleClick = javax.swing.Timer( + ((Toolkit.getDefaultToolkit().getDesktopProperty("awt.multiClickInterval") as? Int) ?: 300).coerceAtMost(300), + ) { _events.trySend(MpvEvent.Click) }.apply { isRepeats = false } + val canvas: Canvas = object : Canvas() { override fun addNotify() { super.addNotify() @@ -115,32 +141,57 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { } override fun removeNotify() { + singleClick.stop() shutdown() super.removeNotify() } - }.apply { background = Color.BLACK } + }.apply { + background = Color.BLACK + // Keys stay with the app, which handles them the same inside and outside the video + isFocusable = false + // Embedded mpv takes no input itself: its window is disabled, so the mouse reaches this canvas + val mouse = object : MouseAdapter() { + override fun mouseMoved(e: MouseEvent) { + onMouseMove?.invoke() + } + + override fun mouseDragged(e: MouseEvent) = mouseMoved(e) + + override fun mouseClicked(e: MouseEvent) { + if (e.button != MouseEvent.BUTTON1) return + when { + e.clickCount == 1 -> singleClick.restart() + e.clickCount == 2 -> { + singleClick.stop() + _events.trySend(MpvEvent.ToggleFullscreen) + } + } + } + + override fun mouseWheelMoved(e: MouseWheelEvent) { + // The wheel changes the volume, as in most desktop players + changeVolume(if (e.wheelRotation < 0) 5 else -5) + } + } + addMouseListener(mouse) + addMouseMotionListener(mouse) + addMouseWheelListener(mouse) + } private fun start(windowId: Long) = synchronized(this) { if (started || closed) return started = true option("wid", windowId.toString()) - option("osc", "yes") - option("input-default-bindings", "yes") - option("input-vo-keyboard", "yes") + // The app draws its own controls. mpv's would not work anyway: embedded, it gets no mouse input + option("osc", "no") option("hwdec", "auto-safe") // Stay on the last frame at the end instead of closing the file option("keep-open", "yes") option("idle", "yes") option("force-window", "yes") option("sub-auto", "no") - // Between files mpv would show its own logo and invite dropping files, neither belongs here - option("script-opts", "osc-idlescreen=no") option("drag-and-drop", "no") check("initialize", lib.mpv_initialize(ctx)) - // Keys inside the video go to mpv, so the app's own keys are rebound to messages it sends back - for ((key, message) in listOf("ESC" to "cs-back", "BS" to "cs-back", "f" to "cs-fullscreen", "MBTN_LEFT_DBL" to "cs-fullscreen")) { - command("keybind", key, "script-message $message") - } eventThread = Thread(::eventLoop, "mpv-events").apply { isDaemon = true; start() } pending.forEach { it() } pending.clear() @@ -175,6 +226,11 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { val event = lib.mpv_wait_event(ctx, -1.0) when (event.getInt(0)) { MPV_EVENT_SHUTDOWN -> return + MPV_EVENT_FILE_LOADED -> synchronized(this) { + fileLoaded = true + afterLoad.forEach { it() } + afterLoad.clear() + } MPV_EVENT_END_FILE -> { val data = event.getPointer(16) val reason = data.getInt(0) @@ -184,15 +240,6 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { END_FILE_REASON_ERROR -> _events.trySend(MpvEvent.Failed(url, lib.mpv_error_string(data.getInt(4)))) } } - - MPV_EVENT_CLIENT_MESSAGE -> { - val data = event.getPointer(16) - if (data.getInt(0) < 1) continue - when (data.getPointer(8).getPointer(0).getString(0)) { - "cs-back" -> _events.trySend(MpvEvent.Back) - "cs-fullscreen" -> _events.trySend(MpvEvent.ToggleFullscreen) - } - } } } } finally { @@ -219,7 +266,7 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { * Plays a link with the headers the provider gave for it. The referer and user agent have their * own mpv options, every other header is sent as is on every request, playlists and segments included. */ - fun play(link: ExtractorLink, startSeconds: Double? = null) = whenStarted { + fun play(link: ExtractorLink, startSeconds: Double? = null, subtitles: List = emptyList()) = whenStarted { val headers = link.headers.toMutableMap() fun take(name: String) = headers.keys.firstOrNull { it.equals(name, ignoreCase = true) }?.let { headers.remove(it) } val userAgent = take("User-Agent") ?: USER_AGENT @@ -235,15 +282,27 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { command("set", "start", startSeconds?.let { "+$it" } ?: "none") // keep-open pauses at the end of a file, which would carry over to the next one command("set", "pause", "no") + fileLoaded = false + afterLoad.clear() command("loadfile", link.url, "replace") link.audioTracks.forEach { command("audio-add", it.url, "auto") } + // A new file drops the subtitles added to the last one + subtitles.forEach { addSubtitle(it.file, it.title, it.select) } + } + + /** Runs once the current file is loaded */ + private fun whenLoaded(action: () -> Unit) = whenStarted { + if (fileLoaded) action() else afterLoad += action } - /** Adds a subtitle file and shows it */ - fun addSubtitle(file: File, title: String) = whenStarted { - command("sub-add", file.absolutePath, "select", title) + /** Adds a subtitle file, shown when [select] is true */ + fun addSubtitle(file: File, title: String, select: Boolean = true) = whenLoaded { + command("sub-add", file.absolutePath, if (select) "select" else "auto", title) } + /** Hides subtitles once the file is loaded, for a new link after the user turned them off */ + fun hideSubtitlesWhenLoaded() = whenLoaded { command("set", "sid", "no") } + /** Shows a subtitle track, or hides subtitles when [id] is null */ fun selectSubtitle(id: Int?) = whenStarted { command("set", "sid", id?.toString() ?: "no") } @@ -253,8 +312,20 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun seek(seconds: Double) = whenStarted { command("seek", seconds.toString(), "relative") } + fun seekTo(seconds: Double) = whenStarted { command("seek", seconds.toString(), "absolute") } + fun changeVolume(delta: Int) = whenStarted { command("add", "volume", delta.toString()) } + fun setVolume(volume: Int) = whenStarted { command("set", "volume", volume.coerceIn(0, 100).toString()) } + + fun toggleMute() = whenStarted { command("cycle", "mute") } + + fun setSpeed(speed: Double) = whenStarted { command("set", "speed", speed.toString()) } + + fun changeSubDelay(seconds: Double) = whenStarted { command("add", "sub-delay", seconds.toString()) } + + fun resetSubDelay() = whenStarted { command("set", "sub-delay", "0") } + fun status(): MpvStatus { if (!started || closed) return MpvStatus() val count = property("track-list/count")?.toIntOrNull() ?: 0 @@ -267,6 +338,7 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { lang = property("${prefix}lang"), selected = property("${prefix}selected") == "yes", external = property("${prefix}external") == "yes", + externalFilename = property("${prefix}external-filename"), ) } return MpvStatus( @@ -275,13 +347,17 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { paused = property("pause") == "yes", buffering = property("paused-for-cache") == "yes", tracks = tracks, + volume = property("volume")?.toDoubleOrNull()?.toInt() ?: 100, + muted = property("mute") == "yes", + speed = property("speed")?.toDoubleOrNull() ?: 1.0, + subDelay = property("sub-delay")?.toDoubleOrNull() ?: 0.0, ) } private companion object { const val MPV_EVENT_SHUTDOWN = 1 const val MPV_EVENT_END_FILE = 7 - const val MPV_EVENT_CLIENT_MESSAGE = 16 + const val MPV_EVENT_FILE_LOADED = 8 const val END_FILE_REASON_EOF = 0 const val END_FILE_REASON_ERROR = 4 } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index f779b394bb9..2b7d6c31ed5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream4.player 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.Column import androidx.compose.foundation.layout.Row @@ -18,6 +19,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -25,12 +27,14 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.focus.FocusRequester @@ -41,23 +45,46 @@ 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.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream4.library.DataStoreWatchStore -import com.lagradost.cloudstream4.library.saveProgress import com.lagradost.cloudstream4.browse.Message +import com.lagradost.cloudstream4.browse.formatTime import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.arrow_back +import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_24 +import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_exit_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_volume_mute_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_volume_up_24 +import com.lagradost.cloudstream4.generated.resources.pause_24px +import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.library.saveProgress import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource +import java.io.File +import kotlin.math.abs + +/** Which subtitle the user picked, so it is shown again when the source changes */ +private sealed interface SubtitleChoice { + /** Nothing picked: mpv's own choice */ + data object Default : SubtitleChoice + data object Off : SubtitleChoice + /** An extension's subtitle, by its url */ + data class Extension(val url: String) : SubtitleChoice +} /** * Plays a movie or episode. The links load in the background and the best one starts on its own. - * The bar on top switches source, subtitles and audio; mpv's own controls handle seeking. - * Space pauses, the arrows seek and change volume, F toggles full screen and Escape goes back. + * The bar on top switches source, subtitles, audio and speed; the bar below plays, seeks and sets + * the volume. In full screen the bars show while the mouse moves and hide after a few seconds. + * Space pauses, the arrows seek and change volume, M mutes, F toggles full screen and Escape closes + * an open panel or goes back. A click on the video closes an open panel, or pauses. */ +@OptIn(ExperimentalComposeUiApi::class) @Composable fun PlayerScreen( viewModel: LinksViewModel, @@ -74,11 +101,15 @@ fun PlayerScreen( val player = remember { MpvPlayer(lib) } var status by remember { mutableStateOf(MpvStatus()) } val scope = rememberCoroutineScope() - var subtitleError by remember { mutableStateOf(null) } + var subtitleMessage by remember { mutableStateOf(null) } var panel by remember { mutableStateOf(null) } - val currentBack by rememberUpdatedState(onBack) val currentFullscreen by rememberUpdatedState(onToggleFullscreen) + // Extension subtitles downloaded so far, by url, and which one is shown + val downloaded = remember { mutableStateMapOf() } + var subtitleChoice by remember { mutableStateOf(SubtitleChoice.Default) } + var loadingSubtitle by remember { mutableStateOf(null) } + // Where playback is, in ms: it starts where the user left off, and a new source carries on from there val tracking = viewModel.request.tracking val playedTo = remember { longArrayOf(tracking?.startPositionMs ?: 0, 0) } @@ -88,49 +119,96 @@ fun PlayerScreen( } DisposableEffect(Unit) { onDispose { saveProgress() } } - // A new link was picked, by hand or because the last one failed + // A new link was picked, by hand or because the last one failed. The subtitle picked stays on val selected = state.selectedLink LaunchedEffect(selected?.url) { - if (selected != null) player.play(selected, playedTo[0].takeIf { it > 0 }?.let { it / 1000.0 }) + if (selected == null) return@LaunchedEffect + val choice = subtitleChoice + val subtitles = state.subtitles.mapNotNull { sub -> + downloaded[sub.url]?.let { MpvSubtitle(it, sub.lang, select = choice == SubtitleChoice.Extension(sub.url)) } + } + player.play(selected, playedTo[0].takeIf { it > 0 }?.let { it / 1000.0 }, subtitles) + if (choice == SubtitleChoice.Off) player.hideSubtitlesWhenLoaded() + } + // In full screen the bars show on mouse moves and hide once the mouse rests, unless a panel is open + var activity by remember { mutableStateOf(0L) } + var barsShown by remember { mutableStateOf(true) } + // Showing or hiding the bars resizes what is under the mouse, which reports a move too: only a + // pointer that really moved on screen counts + val lastPointer = remember { arrayOfNulls(1) } + fun pointerMoved() { + val at = java.awt.MouseInfo.getPointerInfo()?.location ?: return + if (at == lastPointer[0]) return + lastPointer[0] = at + activity = System.nanoTime() } + DisposableEffect(player) { + player.onMouseMove = ::pointerMoved + onDispose { player.onMouseMove = null } + } + LaunchedEffect(fullscreen, activity, panel) { + barsShown = true + if (fullscreen && panel == null) { + delay(3000) + barsShown = false + } + } + val showBars = !fullscreen || barsShown LaunchedEffect(player) { player.events.collect { event -> when (event) { - MpvEvent.Back -> currentBack() MpvEvent.ToggleFullscreen -> currentFullscreen() + MpvEvent.Click -> if (panel != null) panel = null else player.togglePause() is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(event.url, event.message)) MpvEvent.Ended -> saveProgress(playedTo[1], playedTo[1]) } } } LaunchedEffect(player) { - var seconds = 0 + var ticks = 0 while (true) { status = player.status() if (status.duration > 0 && status.position > 0) { playedTo[0] = (status.position * 1000).toLong() playedTo[1] = (status.duration * 1000).toLong() - if (++seconds % 10 == 0) saveProgress() + // Every 10 seconds + if (++ticks % 20 == 0) saveProgress() } - delay(1000) + delay(500) } } fun showSubtitle(subtitle: SubtitleFile) { - subtitleError = null + subtitleMessage = null + subtitleChoice = SubtitleChoice.Extension(subtitle.url) + val file = downloaded[subtitle.url] + val track = file?.let { status.tracks.firstOrNull { track -> track.isFile(it) } } + if (track != null) { + player.selectSubtitle(track.id) + return + } + loadingSubtitle = subtitle.url scope.launch { runCatching { Subtitles.download(subtitle) } - .onSuccess { player.addSubtitle(it, subtitle.lang) } - .onFailure { subtitleError = "Subtitle could not be loaded: ${it.message}" } + .onSuccess { + downloaded[subtitle.url] = it + player.addSubtitle(it, subtitle.lang) + } + .onFailure { subtitleMessage = "Subtitle could not be loaded: ${it.message}" } + loadingSubtitle = null } } val focus = remember { FocusRequester() } LaunchedEffect(Unit) { focus.requestFocus() } + // Clicks on the bars outside their buttons close an open panel + val closePanel = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { panel = null } Column( Modifier .fillMaxSize() .background(Color.Black) + // Moves over the bars, which the video does not see + .onPointerEvent(PointerEventType.Move) { pointerMoved() } .focusRequester(focus) .focusable() .onPreviewKeyEvent { @@ -141,42 +219,69 @@ fun PlayerScreen( Key.DirectionRight -> player.seek(10.0) Key.DirectionUp -> player.changeVolume(5) Key.DirectionDown -> player.changeVolume(-5) + Key.M -> player.toggleMute() Key.F -> onToggleFullscreen() + Key.Escape -> if (panel != null) panel = null else return@onPreviewKeyEvent false else -> return@onPreviewKeyEvent false } true }, ) { - if (!fullscreen) TopBar( + if (showBars) TopBar( state = state, status = status, title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), - message = subtitleError, + message = subtitleMessage, panel = panel, onPanel = { panel = it }, onBack = onBack, onRetry = { viewModel.onAction(LinksAction.Retry) }, - onToggleFullscreen = onToggleFullscreen, + modifier = closePanel, ) - // The video is a native window, so nothing can be drawn over it: messages go in the bar above + // The video is a native window, so nothing can be drawn over it: panels open beside it Row(Modifier.fillMaxWidth().weight(1f)) { SwingPanel(factory = { player.canvas }, modifier = Modifier.weight(1f).fillMaxHeight(), background = Color.Black) val open = panel - if (open != null && !fullscreen) SidePanel( + if (open != null && showBars) SidePanel( panel = open, state = state, status = status, + downloaded = downloaded, + loadingSubtitle = loadingSubtitle, onSelect = { viewModel.onAction(LinksAction.Select(it)) }, onSubtitle = ::showSubtitle, - onSubtitleTrack = player::selectSubtitle, + onSubtitleTrack = { id -> + subtitleChoice = if (id == null) SubtitleChoice.Off else SubtitleChoice.Default + player.selectSubtitle(id) + }, + onSubDelay = { if (it == 0.0) player.resetSubDelay() else player.changeSubDelay(it) }, onAudioTrack = player::selectAudio, + onSpeed = player::setSpeed, ) } + if (showBars) ControlBar(player, status, fullscreen, onToggleFullscreen, closePanel) } } -/** The pickers open beside the video: the video is a native window, so nothing can be drawn over it */ -private enum class Panel(val title: String) { Source("Source"), Subtitles("Subtitles"), Audio("Audio") } +/** "English", or "English 1", "English 2" when there are several, by url */ +private fun subtitleLabels(subtitles: List): Map { + val counts = subtitles.groupingBy { it.lang }.eachCount() + val seen = HashMap() + return subtitles.associate { sub -> + val n = seen.merge(sub.lang, 1, Int::plus)!! + sub.url to if (counts.getValue(sub.lang) > 1) "${sub.lang} $n" else sub.lang + } +} + +private fun host(url: String): String? = runCatching { java.net.URI(url).host?.removePrefix("www.") }.getOrNull() + +private fun MpvTrack.isFile(file: File) = external && externalFilename?.let { File(it).absolutePath.equals(file.absolutePath, ignoreCase = true) } == true + +private enum class Panel(val title: String) { Source("Source"), Subtitles("Subtitles"), Audio("Audio"), Speed("Speed") } + +private val speeds = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0) + +private fun speedLabel(speed: Double) = "%s×".format(if (speed % 1.0 == 0.0) speed.toInt().toString() else speed.toString()) @Composable private fun TopBar( @@ -188,10 +293,10 @@ private fun TopBar( onPanel: (Panel?) -> Unit, onBack: () -> Unit, onRetry: () -> Unit, - onToggleFullscreen: () -> Unit, + modifier: Modifier = Modifier, ) { Row( - Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 8.dp, vertical = 4.dp), + modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -219,8 +324,13 @@ private fun TopBar( Panel.Source -> state.links.isNotEmpty() Panel.Subtitles -> state.subtitles.isNotEmpty() || status.tracks.any { it.type == "sub" } Panel.Audio -> audioTracks > 1 + Panel.Speed -> true + } + val label = when { + entry == Panel.Source && state.links.isNotEmpty() -> "Source · ${state.links.size}" + entry == Panel.Speed && status.speed != 1.0 -> "Speed · ${speedLabel(status.speed)}" + else -> entry.title } - val label = if (entry == Panel.Source && state.links.isNotEmpty()) "Source · ${state.links.size}" else entry.title // The audio button only shows when there is a choice to make if (entry != Panel.Audio || enabled) { TextButton(onClick = { onPanel(if (panel == entry) null else entry) }, enabled = enabled) { @@ -228,7 +338,78 @@ private fun TopBar( } } } - TextButton(onClick = onToggleFullscreen) { Text("Full screen") } + } +} + +/** Play and pause, the seek bar with the time, volume and full screen */ +@Composable +private fun ControlBar(player: MpvPlayer, status: MpvStatus, fullscreen: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier) { + // While dragging, and after letting go until mpv got there, the bar shows the target, not the old position + var dragging by remember { mutableStateOf(null) } + var seekingTo by remember { mutableStateOf?>(null) } + LaunchedEffect(status) { + seekingTo?.let { (target, at) -> + if (abs(status.position - target) < 2 || System.currentTimeMillis() - at > 3000) seekingTo = null + } + } + var volume by remember { mutableStateOf(null) } + val duration = status.duration.toFloat() + val position = dragging ?: seekingTo?.first ?: status.position.toFloat() + + Row( + modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = player::togglePause) { + Icon( + painterResource(if (status.paused) Res.drawable.play_arrow_24px else Res.drawable.pause_24px), + contentDescription = if (status.paused) "Play" else "Pause", + ) + } + TextButton(onClick = { player.seek(-10.0) }) { Text("−10 s") } + TextButton(onClick = { player.seek(10.0) }) { Text("+10 s") } + Text( + if (duration > 0) "${formatTime((position * 1000).toLong())} / ${formatTime((duration * 1000).toLong())}" else "--:--", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 8.dp), + ) + Slider( + value = position.coerceIn(0f, duration.coerceAtLeast(1f)), + onValueChange = { dragging = it }, + onValueChangeFinished = { + dragging?.let { + player.seekTo(it.toDouble()) + seekingTo = it to System.currentTimeMillis() + } + dragging = null + }, + valueRange = 0f..duration.coerceAtLeast(1f), + enabled = duration > 0, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = player::toggleMute) { + Icon( + painterResource(if (status.muted || status.volume == 0) Res.drawable.ic_baseline_volume_mute_24 else Res.drawable.ic_baseline_volume_up_24), + contentDescription = if (status.muted) "Unmute" else "Mute", + ) + } + Slider( + value = volume ?: status.volume.toFloat(), + onValueChange = { + volume = it + player.setVolume(it.toInt()) + }, + onValueChangeFinished = { volume = null }, + valueRange = 0f..100f, + modifier = Modifier.width(110.dp), + ) + IconButton(onClick = onToggleFullscreen) { + Icon( + painterResource(if (fullscreen) Res.drawable.baseline_fullscreen_exit_24 else Res.drawable.baseline_fullscreen_24), + contentDescription = if (fullscreen) "Leave full screen" else "Full screen", + ) + } } } @@ -237,45 +418,94 @@ private fun SidePanel( panel: Panel, state: LinksState, status: MpvStatus, + downloaded: Map, + loadingSubtitle: String?, onSelect: (String) -> Unit, onSubtitle: (SubtitleFile) -> Unit, onSubtitleTrack: (Int?) -> Unit, + onSubDelay: (Double) -> Unit, onAudioTrack: (Int) -> Unit, + onSpeed: (Double) -> Unit, ) { - LazyColumn(Modifier.width(320.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface)) { + LazyColumn(Modifier.width(380.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface)) { item { Text(panel.title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(16.dp)) } when (panel) { + // Names are shown whole: many sources differ only at the end of their name Panel.Source -> items(state.links) { link -> Choice( text = LinksViewModel.label(link), detail = state.failed[link.url]?.let { "Failed: $it" } ?: link.source.takeIf { it != link.name }, selected = link.url == state.selected, error = link.url in state.failed, + maxLines = Int.MAX_VALUE, ) { onSelect(link.url) } } Panel.Subtitles -> { val tracks = status.tracks.filter { it.type == "sub" } - item { Choice("Off", selected = tracks.none { it.selected }) { onSubtitleTrack(null) } } - items(tracks) { track -> - Choice(track.label, detail = if (track.external) "From the extension" else "In the video", selected = track.selected) { - onSubtitleTrack(track.id) - } + val shown = tracks.firstOrNull { it.selected } + // Timing first: a video can hold dozens of subtitle tracks + if (shown != null) item { SubtitleTiming(status.subDelay, onSubDelay) } + item { Choice("Off", selected = shown == null) { onSubtitleTrack(null) } } + // Every extension subtitle, downloaded the first time it is picked + // Sorted by language. Extensions often give several of one: they are numbered and show where they come from + val subtitles = state.subtitles.sortedBy { it.lang.lowercase() } + val labels = subtitleLabels(subtitles) + items(subtitles, key = { it.url }) { sub -> + val track = downloaded[sub.url]?.let { file -> tracks.firstOrNull { it.isFile(file) } } + Choice( + labels.getValue(sub.url), + detail = if (loadingSubtitle == sub.url) "Loading…" else listOfNotNull("From the extension", host(sub.url)).joinToString(" · "), + selected = track?.selected == true, + ) { onSubtitle(sub) } + } + items(tracks.filter { !it.external }) { track -> + Choice(track.label, detail = "In the video", selected = track.selected) { onSubtitleTrack(track.id) } } - // Extension subtitles not added yet, each is downloaded when picked - val notAdded = state.subtitles.filter { sub -> tracks.none { it.external && it.title == sub.lang } } - items(notAdded) { sub -> Choice(sub.lang, detail = "From the extension", selected = false) { onSubtitle(sub) } } } Panel.Audio -> items(status.tracks.filter { it.type == "audio" }) { track -> Choice(track.label, selected = track.selected) { onAudioTrack(track.id) } } + + Panel.Speed -> items(speeds) { speed -> + Choice(speedLabel(speed) + if (speed == 1.0) " (normal)" else "", selected = abs(status.speed - speed) < 0.01) { onSpeed(speed) } + } + } + } +} + +/** Moves the subtitles earlier or later when they are out of sync with the speech */ +@Composable +private fun SubtitleTiming(delay: Double, onChange: (Double) -> Unit) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text("Timing", style = MaterialTheme.typography.titleSmall) + Text( + when { + abs(delay) < 0.05 -> "In sync with the video" + delay > 0 -> "Shown %.1f s later".format(delay) + else -> "Shown %.1f s earlier".format(-delay) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { onChange(-0.5) }) { Text("Earlier") } + TextButton(onClick = { onChange(0.5) }) { Text("Later") } + if (abs(delay) >= 0.05) TextButton(onClick = { onChange(0.0) }) { Text("Reset") } } } } @Composable -private fun Choice(text: String, detail: String? = null, selected: Boolean, error: Boolean = false, onClick: () -> Unit) { +private fun Choice( + text: String, + detail: String? = null, + selected: Boolean, + error: Boolean = false, + maxLines: Int = 2, + onClick: () -> Unit, +) { Column( Modifier .fillMaxWidth() @@ -283,13 +513,13 @@ private fun Choice(text: String, detail: String? = null, selected: Boolean, erro .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) .padding(horizontal = 16.dp, vertical = 10.dp) ) { - Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = maxLines, overflow = TextOverflow.Ellipsis) detail?.let { Text( it, style = MaterialTheme.typography.bodySmall, color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) } diff --git a/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_24.xml new file mode 100644 index 00000000000..d484e18c451 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_exit_24.xml b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_exit_24.xml new file mode 100644 index 00000000000..df4ebd37ae8 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/baseline_fullscreen_exit_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_mute_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_mute_24.xml new file mode 100644 index 00000000000..3ab92ed430b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_mute_24.xml @@ -0,0 +1,4 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_up_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_up_24.xml new file mode 100644 index 00000000000..7640312dd84 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_volume_up_24.xml @@ -0,0 +1,4 @@ + + + From 22b2565b7280de90b7a098ea1e5a2181271a678f Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 17:41:04 +0530 Subject: [PATCH 14/64] Desktop: player redesign, buffering spinner and audio picker - Loading and buffering show as a spinner with text drawn by mpv over the video: finding links, opening the file, seeking, or the cache running empty ("Buffering 45%"). - Dark player bars in every theme. A thin seek bar across the top of the controls shows what is played and what is loaded, thickens under the mouse and shows the time under it. Icon buttons for 10 s back and forward, mute and volume; Subtitles, Audio (with the track count), Speed and Source (with the quality) on the right. - The Audio button is always there. Tracks show their language in full, channels, codec and the default; with one track the panel says other sources may have other languages. - A link that ends within seconds of starting counts as failed and the next one plays, and progress is only saved once a link really plays, so a broken file no longer marks an episode watched. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 53 +- .../cloudstream4/player/PlayerScreen.kt | 529 ++++++++++++++---- .../drawable/ic_baseline_audiotrack_24.xml | 9 + .../drawable/ic_baseline_hd_24.xml | 5 + .../drawable/ic_baseline_replay_24.xml | 9 + .../drawable/ic_baseline_subtitles_24.xml | 5 + 6 files changed, 491 insertions(+), 119 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_audiotrack_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_hd_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_replay_24.xml create mode 100644 shared/src/commonMain/composeResources/drawable/ic_baseline_subtitles_24.xml diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 9607e1db36c..e2611fa6209 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -74,15 +74,43 @@ data class MpvTrack( val external: Boolean, /** The file an external track was added from */ val externalFilename: String? = null, + /** For example "eac3" or "subrip" */ + val codec: String? = null, + /** Audio channels, for example 6 for 5.1 */ + val channels: Int? = null, + val default: Boolean = false, ) { - val label: String get() = listOfNotNull(title, lang).distinct().joinToString(" · ").ifEmpty { "Track $id" } + /** The language spelled out, for example "Hindi" for "hin" */ + val language: String? get() = lang?.let(::languageName) + + /** For example "Hindi", "Commentary · English" or "Track 2" */ + val label: String get() = listOfNotNull(title, language).distinctBy { it.lowercase() }.joinToString(" · ").ifEmpty { "Track $id" } + + /** For example "5.1 · EAC3", what tells two tracks in one language apart */ + val details: String get() = listOfNotNull( + channels?.let { when (it) { 1 -> "Mono"; 2 -> "Stereo"; 6 -> "5.1"; 8 -> "7.1"; else -> "$it channels" } }, + codec?.uppercase(), + "Default".takeIf { default }, + ).joinToString(" · ") +} + +/** "Hindi" for "hin" or "hi", the code itself when it is not a language */ +internal fun languageName(code: String): String { + val locale = java.util.Locale.forLanguageTag(code) + val name = locale.getDisplayLanguage(java.util.Locale.ENGLISH) + return if (name.isBlank() || name.equals(code, ignoreCase = true)) code else name } data class MpvStatus( val position: Double = 0.0, val duration: Double = 0.0, val paused: Boolean = false, + /** Waiting for data: the file is opening, a seek is under way or the cache ran empty */ val buffering: Boolean = false, + /** How full the cache is before playback goes on, 0 to 100, when mpv knows */ + val bufferPercent: Int? = null, + /** How far ahead of the start the cache reaches, in seconds */ + val bufferedTo: Double = 0.0, val tracks: List = emptyList(), /** 0 to 100 */ val volume: Int = 100, @@ -121,6 +149,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { @Volatile private var currentUrl: String? = null /** Whether the current file finished loading. Subtitles can only be added to a loaded file */ private var fileLoaded = false + /** From loadfile until the first frame plays */ + @Volatile private var opening = false private val afterLoad = mutableListOf<() -> Unit>() private val _events = Channel(Channel.UNLIMITED) @@ -226,6 +256,7 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { val event = lib.mpv_wait_event(ctx, -1.0) when (event.getInt(0)) { MPV_EVENT_SHUTDOWN -> return + MPV_EVENT_PLAYBACK_RESTART -> opening = false MPV_EVENT_FILE_LOADED -> synchronized(this) { fileLoaded = true afterLoad.forEach { it() } @@ -235,6 +266,7 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { val data = event.getPointer(16) val reason = data.getInt(0) val url = currentUrl ?: continue + if (reason == END_FILE_REASON_ERROR) opening = false when (reason) { END_FILE_REASON_EOF -> _events.trySend(MpvEvent.Ended) END_FILE_REASON_ERROR -> _events.trySend(MpvEvent.Failed(url, lib.mpv_error_string(data.getInt(4)))) @@ -283,6 +315,7 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { // keep-open pauses at the end of a file, which would carry over to the next one command("set", "pause", "no") fileLoaded = false + opening = true afterLoad.clear() command("loadfile", link.url, "replace") link.audioTracks.forEach { command("audio-add", it.url, "auto") } @@ -326,6 +359,15 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun resetSubDelay() = whenStarted { command("set", "sub-delay", "0") } + /** + * Draws [ass] over the video, in ASS on a 1280 by 720 canvas, or removes it when null. The + * video is a native window, so this is the only way to show something on top of it. + */ + fun overlay(ass: String?) = whenStarted { + if (ass == null) command("osd-overlay", OVERLAY_ID, "none", "") + else command("osd-overlay", OVERLAY_ID, "ass-events", ass, "1280", "720") + } + fun status(): MpvStatus { if (!started || closed) return MpvStatus() val count = property("track-list/count")?.toIntOrNull() ?: 0 @@ -339,13 +381,18 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { selected = property("${prefix}selected") == "yes", external = property("${prefix}external") == "yes", externalFilename = property("${prefix}external-filename"), + codec = property("${prefix}codec"), + channels = property("${prefix}demux-channel-count")?.toIntOrNull(), + default = property("${prefix}default") == "yes", ) } return MpvStatus( position = property("time-pos")?.toDoubleOrNull() ?: 0.0, duration = property("duration")?.toDoubleOrNull() ?: 0.0, paused = property("pause") == "yes", - buffering = property("paused-for-cache") == "yes", + buffering = opening || property("paused-for-cache") == "yes" || property("seeking") == "yes", + bufferPercent = property("cache-buffering-state")?.toIntOrNull(), + bufferedTo = property("demuxer-cache-time")?.toDoubleOrNull() ?: 0.0, tracks = tracks, volume = property("volume")?.toDoubleOrNull()?.toInt() ?: 100, muted = property("mute") == "yes", @@ -358,6 +405,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { const val MPV_EVENT_SHUTDOWN = 1 const val MPV_EVENT_END_FILE = 7 const val MPV_EVENT_FILE_LOADED = 8 + const val MPV_EVENT_PLAYBACK_RESTART = 21 + const val OVERLAY_ID = "1" const val END_FILE_REASON_EOF = 0 const val END_FILE_REASON_ERROR = 4 } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 2b7d6c31ed5..f9225376fe4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -1,6 +1,13 @@ package com.lagradost.cloudstream4.player +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -19,7 +26,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -37,6 +43,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color @@ -50,12 +67,18 @@ import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream4.browse.Message import com.lagradost.cloudstream4.browse.formatTime import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.arrow_back import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_24 import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_exit_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_audiotrack_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_hd_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_replay_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_speed_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_subtitles_24 import com.lagradost.cloudstream4.generated.resources.ic_baseline_volume_mute_24 import com.lagradost.cloudstream4.generated.resources.ic_baseline_volume_up_24 import com.lagradost.cloudstream4.generated.resources.pause_24px @@ -64,6 +87,7 @@ import com.lagradost.cloudstream4.library.DataStoreWatchStore import com.lagradost.cloudstream4.library.saveProgress import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import java.io.File import kotlin.math.abs @@ -79,8 +103,8 @@ private sealed interface SubtitleChoice { /** * Plays a movie or episode. The links load in the background and the best one starts on its own. - * The bar on top switches source, subtitles, audio and speed; the bar below plays, seeks and sets - * the volume. In full screen the bars show while the mouse moves and hide after a few seconds. + * The bar on top shows the title and source; the bar below plays, seeks, sets the volume and opens + * the subtitle, audio, speed and source pickers. In full screen the bars show while the mouse moves and hide after a few seconds. * Space pauses, the arrows seek and change volume, M mutes, F toggles full screen and Escape closes * an open panel or goes back. A click on the video closes an open panel, or pauses. */ @@ -119,10 +143,17 @@ fun PlayerScreen( } DisposableEffect(Unit) { onDispose { saveProgress() } } + // Seconds really played on the current link, and the last position seen, to tell a link that + // ends right after it starts (a broken file) from one watched to the end + val playedOnLink = remember { doubleArrayOf(0.0, -1.0) } + val currentState by rememberUpdatedState(state) + // A new link was picked, by hand or because the last one failed. The subtitle picked stays on val selected = state.selectedLink LaunchedEffect(selected?.url) { if (selected == null) return@LaunchedEffect + playedOnLink[0] = 0.0 + playedOnLink[1] = -1.0 val choice = subtitleChoice val subtitles = state.subtitles.mapNotNull { sub -> downloaded[sub.url]?.let { MpvSubtitle(it, sub.lang, select = choice == SubtitleChoice.Extension(sub.url)) } @@ -160,7 +191,14 @@ fun PlayerScreen( MpvEvent.ToggleFullscreen -> currentFullscreen() MpvEvent.Click -> if (panel != null) panel = null else player.togglePause() is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(event.url, event.message)) - MpvEvent.Ended -> saveProgress(playedTo[1], playedTo[1]) + MpvEvent.Ended -> { + val url = currentState.selected + // A file that stops within seconds of starting is broken: it must not count as watched + // unless it was started in its last half minute + val nearEnd = playedTo[1] > 0 && playedTo[0] >= playedTo[1] - 30_000 + if (playedOnLink[0] < 10 && !nearEnd && url != null) viewModel.onAction(LinksAction.Failed(url, "Stopped right after it started")) + else saveProgress(playedTo[1], playedTo[1]) + } } } } @@ -168,7 +206,11 @@ fun PlayerScreen( var ticks = 0 while (true) { status = player.status() - if (status.duration > 0 && status.position > 0) { + val last = playedOnLink[1] + if (last >= 0 && status.position > last && status.position - last < 2) playedOnLink[0] += status.position - last + playedOnLink[1] = status.position + // Only once the link really plays: a broken one can jump to its end, which is no progress + if (status.duration > 0 && status.position > 0 && playedOnLink[0] >= 3) { playedTo[0] = (status.position * 1000).toLong() playedTo[1] = (status.duration * 1000).toLong() // Every 10 seconds @@ -178,6 +220,26 @@ fun PlayerScreen( } } + // While links are found and the video opens or buffers, a spinner turns over the video + val indicator = when { + state.exhausted -> null + state.selected == null -> if (state.links.isEmpty()) "Finding links…" else "Finding links · ${state.links.size} found" + status.buffering -> status.bufferPercent?.takeIf { it in 1..99 }?.let { "Buffering $it%" } ?: "Loading…" + else -> null + } + val currentIndicator by rememberUpdatedState(indicator) + LaunchedEffect(player, indicator != null) { + if (indicator == null) { + player.overlay(null) + return@LaunchedEffect + } + var step = 0 + while (true) { + currentIndicator?.let { player.overlay(spinnerAss(step++, it)) } + delay(80) + } + } + fun showSubtitle(subtitle: SubtitleFile) { subtitleMessage = null subtitleChoice = SubtitleChoice.Extension(subtitle.url) @@ -228,14 +290,18 @@ fun PlayerScreen( }, ) { if (showBars) TopBar( - state = state, - status = status, title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), - message = subtitleMessage, - panel = panel, - onPanel = { panel = it }, + line = subtitleMessage ?: when { + state.exhausted && state.links.isEmpty() -> "No playable links found" + + if (state.skipped > 0) " (${state.skipped} torrent or DRM links cannot play on desktop)" else "" + state.exhausted -> "None of the ${state.links.size} links could be played" + state.selected == null -> "Finding links… ${state.links.size} found" + else -> state.selectedLink?.let(LinksViewModel::label) + }, + error = subtitleMessage != null || state.exhausted, + loading = state.loading, onBack = onBack, - onRetry = { viewModel.onAction(LinksAction.Retry) }, + onRetry = if (state.exhausted) ({ viewModel.onAction(LinksAction.Retry) }) else null, modifier = closePanel, ) // The video is a native window, so nothing can be drawn over it: panels open beside it @@ -259,7 +325,17 @@ fun PlayerScreen( onSpeed = player::setSpeed, ) } - if (showBars) ControlBar(player, status, fullscreen, onToggleFullscreen, closePanel) + if (showBars) ControlBar( + player = player, + status = status, + state = state, + panel = panel, + subtitleOn = status.tracks.any { it.type == "sub" && it.selected }, + fullscreen = fullscreen, + onPanel = { panel = it }, + onToggleFullscreen = onToggleFullscreen, + modifier = closePanel, + ) } } @@ -283,132 +359,320 @@ private val speeds = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0) private fun speedLabel(speed: Double) = "%s×".format(if (speed % 1.0 == 0.0) speed.toInt().toString() else speed.toString()) +/** The player is always dark, whatever the app's theme, like most video players */ +private object PlayerColors { + val bar = Color(0xFF111114) + val panel = Color(0xFF18181C) + val content = Color.White + val secondary = Color.White.copy(alpha = 0.68f) + val track = Color.White.copy(alpha = 0.22f) + val buffered = Color.White.copy(alpha = 0.42f) + val selected = Color.White.copy(alpha = 0.10f) +} + +/** The app's accent, in the tone that reads on a dark background */ +@Composable +private fun accent(): Color { + val scheme = MaterialTheme.colorScheme + // A light theme's primary is dark, its inverse is the light tone of the same colour + return if (scheme.background.luminance() > 0.5f) scheme.inversePrimary else scheme.primary +} + +/** + * A spinner and a line of text, drawn by mpv over the video: twelve dots around a dim disc, the + * brightest one moving round as [step] grows. + */ +private fun spinnerAss(step: Int, text: String): String { + val cx = 640.0 + val cy = 330.0 + val lines = mutableListOf() + // Dim disc behind, so the spinner reads on a bright frame + lines += "{\\an7\\pos(${cx - 56},${cy - 56})\\bord0\\shad0\\1c&H000000&\\1a&H70&\\p1}" + circle(56.0) + "{\\p0}" + for (i in 0 until 12) { + val angle = Math.toRadians(i * 30.0 - 90) + val x = cx + 30 * kotlin.math.cos(angle) + val y = cy + 30 * kotlin.math.sin(angle) + // The head is opaque, the dots behind it fade out + val age = ((step - i) % 12 + 12) % 12 + val alpha = (age * 20).coerceAtMost(0xD0) + lines += "{\\an7\\pos(${x - 4.5},${y - 4.5})\\bord0\\shad0\\1c&HFFFFFF&\\1a&H%02X&\\p1}".format(alpha) + circle(4.5) + "{\\p0}" + } + lines += "{\\an8\\pos($cx,${cy + 70})\\fs24\\bord1.5\\shad0\\3c&H000000&\\1c&HFFFFFF&}" + text.replace("{", "(").replace("}", ")") + return lines.joinToString("\n") +} + +/** An ASS drawing of a circle of radius [r], from 0,0 to 2r,2r */ +private fun circle(r: Double): String { + val k = r * 0.5523 + fun n(v: Double) = "%.1f".format(java.util.Locale.ROOT, v) + return "m ${n(r)} 0 b ${n(r + k)} 0 ${n(2 * r)} ${n(r - k)} ${n(2 * r)} ${n(r)} " + + "b ${n(2 * r)} ${n(r + k)} ${n(r + k)} ${n(2 * r)} ${n(r)} ${n(2 * r)} " + + "b ${n(r - k)} ${n(2 * r)} 0 ${n(r + k)} 0 ${n(r)} " + + "b 0 ${n(r - k)} ${n(r - k)} 0 ${n(r)} 0" +} + @Composable private fun TopBar( - state: LinksState, - status: MpvStatus, title: String, - message: String?, - panel: Panel?, - onPanel: (Panel?) -> Unit, + line: String?, + error: Boolean, + loading: Boolean, onBack: () -> Unit, - onRetry: () -> Unit, + onRetry: (() -> Unit)?, modifier: Modifier = Modifier, ) { Row( - modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 8.dp, vertical = 4.dp), + modifier.fillMaxWidth().background(PlayerColors.bar).padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - IconButton(onClick = onBack) { Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back") } + IconButton(onClick = onBack) { + Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back", tint = PlayerColors.content) + } Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - val line = message ?: when { - state.exhausted && state.links.isEmpty() -> "No playable links found" + - if (state.skipped > 0) " (${state.skipped} torrent or DRM links cannot play on desktop)" else "" - state.exhausted -> "None of the ${state.links.size} links could be played" - state.selected == null -> "Finding links… ${state.links.size} found" - status.buffering -> "Buffering…" - else -> state.selectedLink?.let(LinksViewModel::label) - } + Text(title, style = MaterialTheme.typography.titleMedium, color = PlayerColors.content, maxLines = 1, overflow = TextOverflow.Ellipsis) line?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - if (state.loading) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) - if (state.exhausted) TextButton(onClick = onRetry) { Text("Try again") } - - val audioTracks = status.tracks.count { it.type == "audio" } - Panel.entries.forEach { entry -> - val enabled = when (entry) { - Panel.Source -> state.links.isNotEmpty() - Panel.Subtitles -> state.subtitles.isNotEmpty() || status.tracks.any { it.type == "sub" } - Panel.Audio -> audioTracks > 1 - Panel.Speed -> true - } - val label = when { - entry == Panel.Source && state.links.isNotEmpty() -> "Source · ${state.links.size}" - entry == Panel.Speed && status.speed != 1.0 -> "Speed · ${speedLabel(status.speed)}" - else -> entry.title - } - // The audio button only shows when there is a choice to make - if (entry != Panel.Audio || enabled) { - TextButton(onClick = { onPanel(if (panel == entry) null else entry) }, enabled = enabled) { - Text((if (panel == entry) "▸ " else "") + label) - } + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (error) MaterialTheme.colorScheme.error else PlayerColors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } + if (loading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = PlayerColors.secondary) + if (onRetry != null) TextButton(onClick = onRetry) { Text("Try again", color = accent()) } } } -/** Play and pause, the seek bar with the time, volume and full screen */ +/** + * The bar under the video: a seek bar across the top showing what is played and what is loaded, + * then play, skip, volume and the time on the left, and the pickers and full screen on the right. + */ @Composable -private fun ControlBar(player: MpvPlayer, status: MpvStatus, fullscreen: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier) { +private fun ControlBar( + player: MpvPlayer, + status: MpvStatus, + state: LinksState, + panel: Panel?, + subtitleOn: Boolean, + fullscreen: Boolean, + onPanel: (Panel?) -> Unit, + onToggleFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { // While dragging, and after letting go until mpv got there, the bar shows the target, not the old position var dragging by remember { mutableStateOf(null) } + var hovering by remember { mutableStateOf(null) } var seekingTo by remember { mutableStateOf?>(null) } LaunchedEffect(status) { seekingTo?.let { (target, at) -> if (abs(status.position - target) < 2 || System.currentTimeMillis() - at > 3000) seekingTo = null } } - var volume by remember { mutableStateOf(null) } val duration = status.duration.toFloat() val position = dragging ?: seekingTo?.first ?: status.position.toFloat() + val accent = accent() - Row( - modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(horizontal = 8.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton(onClick = player::togglePause) { - Icon( - painterResource(if (status.paused) Res.drawable.play_arrow_24px else Res.drawable.pause_24px), - contentDescription = if (status.paused) "Play" else "Pause", - ) - } - TextButton(onClick = { player.seek(-10.0) }) { Text("−10 s") } - TextButton(onClick = { player.seek(10.0) }) { Text("+10 s") } - Text( - if (duration > 0) "${formatTime((position * 1000).toLong())} / ${formatTime((duration * 1000).toLong())}" else "--:--", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 8.dp), - ) - Slider( - value = position.coerceIn(0f, duration.coerceAtLeast(1f)), - onValueChange = { dragging = it }, - onValueChangeFinished = { + Column(modifier.fillMaxWidth().background(PlayerColors.bar).padding(start = 12.dp, end = 12.dp, top = 6.dp, bottom = 4.dp)) { + SeekBar( + value = if (duration > 0) position / duration else 0f, + buffered = if (duration > 0) (status.bufferedTo.toFloat() / duration).coerceIn(0f, 1f) else 0f, + enabled = duration > 0, + accent = accent, + onChange = { dragging = it * duration }, + onHover = { hovering = it?.times(duration) }, + onDone = { dragging?.let { player.seekTo(it.toDouble()) seekingTo = it to System.currentTimeMillis() } dragging = null }, - valueRange = 0f..duration.coerceAtLeast(1f), - enabled = duration > 0, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) - IconButton(onClick = player::toggleMute) { - Icon( - painterResource(if (status.muted || status.volume == 0) Res.drawable.ic_baseline_volume_mute_24 else Res.drawable.ic_baseline_volume_up_24), - contentDescription = if (status.muted) "Unmute" else "Mute", + Row(verticalAlignment = Alignment.CenterVertically) { + PlayerIcon( + if (status.paused) Res.drawable.play_arrow_24px else Res.drawable.pause_24px, + if (status.paused) "Play" else "Pause", + size = 30.dp, + onClick = player::togglePause, + ) + SkipButton(forward = false) { player.seek(-10.0) } + SkipButton(forward = true) { player.seek(10.0) } + var volume by remember { mutableStateOf(null) } + PlayerIcon( + if (status.muted || status.volume == 0) Res.drawable.ic_baseline_volume_mute_24 else Res.drawable.ic_baseline_volume_up_24, + if (status.muted) "Unmute" else "Mute", + onClick = player::toggleMute, + ) + SeekBar( + value = (volume ?: status.volume.toFloat()) / 100f, + accent = PlayerColors.content, + onChange = { + volume = it * 100 + player.setVolume((it * 100).toInt()) + }, + onDone = { volume = null }, + modifier = Modifier.width(96.dp), + ) + // The hovered time shows in place of the position, to see where a click would go + val shown = hovering ?: position + Text( + if (duration > 0) "${formatTime((shown * 1000).toLong())} / ${formatTime((duration * 1000).toLong())}" else "", + style = MaterialTheme.typography.bodyMedium, + color = if (hovering != null) accent else PlayerColors.content, + modifier = Modifier.padding(start = 16.dp), + ) + Box(Modifier.weight(1f)) + + val subtitleTracks = state.subtitles.isNotEmpty() || status.tracks.any { it.type == "sub" } + PanelButton(Res.drawable.ic_baseline_subtitles_24, "Subtitles", panel == Panel.Subtitles, active = subtitleOn, enabled = subtitleTracks) { + onPanel(if (panel == Panel.Subtitles) null else Panel.Subtitles) + } + val audio = status.tracks.count { it.type == "audio" } + PanelButton( + Res.drawable.ic_baseline_audiotrack_24, "Audio", panel == Panel.Audio, + label = if (audio > 1) "$audio" else null, + ) { onPanel(if (panel == Panel.Audio) null else Panel.Audio) } + PanelButton( + Res.drawable.ic_baseline_speed_24, "Speed", panel == Panel.Speed, + label = if (status.speed != 1.0) speedLabel(status.speed) else null, + active = status.speed != 1.0, + ) { onPanel(if (panel == Panel.Speed) null else Panel.Speed) } + PanelButton( + Res.drawable.ic_baseline_hd_24, "Source", panel == Panel.Source, + label = state.selectedLink?.let { Qualities.getStringByInt(it.quality).ifBlank { null } } ?: "Source", + enabled = state.links.isNotEmpty(), + ) { onPanel(if (panel == Panel.Source) null else Panel.Source) } + PlayerIcon( + if (fullscreen) Res.drawable.baseline_fullscreen_exit_24 else Res.drawable.baseline_fullscreen_24, + if (fullscreen) "Leave full screen" else "Full screen", + onClick = onToggleFullscreen, ) } - Slider( - value = volume ?: status.volume.toFloat(), - onValueChange = { - volume = it - player.setVolume(it.toInt()) - }, - onValueChangeFinished = { volume = null }, - valueRange = 0f..100f, - modifier = Modifier.width(110.dp), - ) - IconButton(onClick = onToggleFullscreen) { + } +} + +@Composable +private fun PlayerIcon( + icon: DrawableResource, + description: String, + size: Dp = 24.dp, + tint: Color = PlayerColors.content, + enabled: Boolean = true, + onClick: () -> Unit, +) { + IconButton(onClick = onClick, enabled = enabled) { + Icon(painterResource(icon), contentDescription = description, tint = if (enabled) tint else tint.copy(alpha = 0.3f), modifier = Modifier.size(size)) + } +} + +/** Back or forward 10 seconds: the replay arrow with a 10 inside, mirrored to go forward */ +@Composable +private fun SkipButton(forward: Boolean, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Box(contentAlignment = Alignment.Center) { Icon( - painterResource(if (fullscreen) Res.drawable.baseline_fullscreen_exit_24 else Res.drawable.baseline_fullscreen_24), - contentDescription = if (fullscreen) "Leave full screen" else "Full screen", + painterResource(Res.drawable.ic_baseline_replay_24), + contentDescription = if (forward) "Forward 10 seconds" else "Back 10 seconds", + tint = PlayerColors.content, + modifier = Modifier.size(28.dp).graphicsLayer { if (forward) scaleX = -1f }, ) + Text("10", color = PlayerColors.content, fontSize = 8.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 2.dp)) + } + } +} + +/** Opens a picker: tinted while it is open or its setting is on, with a short label when there is one */ +@Composable +private fun PanelButton( + icon: DrawableResource, + description: String, + open: Boolean, + label: String? = null, + active: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val color = when { + !enabled -> PlayerColors.content.copy(alpha = 0.3f) + open || active -> accent() + else -> PlayerColors.content + } + Row( + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (open) PlayerColors.selected else Color.Transparent) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(painterResource(icon), contentDescription = description, tint = color, modifier = Modifier.size(22.dp)) + label?.let { Text(it, color = color, style = MaterialTheme.typography.labelLarge) } + } +} + +/** + * A thin bar that thickens under the mouse: [value] and [buffered] are fractions. Clicking or + * dragging calls [onChange] with the fraction under the mouse, [onDone] when the mouse is let go. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SeekBar( + value: Float, + accent: Color, + onChange: (Float) -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, + buffered: Float = 0f, + enabled: Boolean = true, + onHover: (Float?) -> Unit = {}, +) { + var width by remember { mutableStateOf(1) } + var hovered by remember { mutableStateOf(false) } + var pressed by remember { mutableStateOf(false) } + val currentChange by rememberUpdatedState(onChange) + val currentDone by rememberUpdatedState(onDone) + val currentHover by rememberUpdatedState(onHover) + fun fraction(x: Float) = (x / width).coerceIn(0f, 1f) + val thick = enabled && (hovered || pressed) + Box( + modifier + .height(20.dp) + .onSizeChanged { width = it.width.coerceAtLeast(1) } + .onPointerEvent(PointerEventType.Enter) { hovered = true } + .onPointerEvent(PointerEventType.Exit) { + hovered = false + currentHover(null) + } + .onPointerEvent(PointerEventType.Move) { if (enabled) currentHover(fraction(it.changes.first().position.x)) } + .pointerInput(enabled) { + if (!enabled) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown() + pressed = true + currentChange(fraction(down.position.x)) + drag(down.id) { change -> + currentChange(fraction(change.position.x)) + change.consume() + } + pressed = false + currentDone() + } + }, + contentAlignment = Alignment.CenterStart, + ) { + Canvas(Modifier.fillMaxWidth().height(if (thick) 6.dp else 3.dp)) { + val r = CornerRadius(size.height / 2) + drawRoundRect(PlayerColors.track, cornerRadius = r) + if (buffered > 0) drawRoundRect(PlayerColors.buffered, size = Size(size.width * buffered, size.height), cornerRadius = r) + drawRoundRect(accent, size = Size(size.width * value.coerceIn(0f, 1f), size.height), cornerRadius = r) + } + if (thick) Canvas(Modifier.fillMaxWidth().height(14.dp)) { + drawCircle(accent, radius = size.height / 2, center = Offset(size.width * value.coerceIn(0f, 1f), size.height / 2)) } } } @@ -427,8 +691,8 @@ private fun SidePanel( onAudioTrack: (Int) -> Unit, onSpeed: (Double) -> Unit, ) { - LazyColumn(Modifier.width(380.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface)) { - item { Text(panel.title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(16.dp)) } + LazyColumn(Modifier.width(380.dp).fillMaxHeight().background(PlayerColors.panel)) { + item { Text(panel.title, style = MaterialTheme.typography.titleMedium, color = PlayerColors.content, modifier = Modifier.padding(16.dp)) } when (panel) { // Names are shown whole: many sources differ only at the end of their name Panel.Source -> items(state.links) { link -> @@ -447,7 +711,6 @@ private fun SidePanel( // Timing first: a video can hold dozens of subtitle tracks if (shown != null) item { SubtitleTiming(status.subDelay, onSubDelay) } item { Choice("Off", selected = shown == null) { onSubtitleTrack(null) } } - // Every extension subtitle, downloaded the first time it is picked // Sorted by language. Extensions often give several of one: they are numbered and show where they come from val subtitles = state.subtitles.sortedBy { it.lang.lowercase() } val labels = subtitleLabels(subtitles) @@ -460,12 +723,31 @@ private fun SidePanel( ) { onSubtitle(sub) } } items(tracks.filter { !it.external }) { track -> - Choice(track.label, detail = "In the video", selected = track.selected) { onSubtitleTrack(track.id) } + Choice(track.label, detail = listOf("In the video", track.details).filter { it.isNotEmpty() }.joinToString(" · "), selected = track.selected) { + onSubtitleTrack(track.id) + } } } - Panel.Audio -> items(status.tracks.filter { it.type == "audio" }) { track -> - Choice(track.label, selected = track.selected) { onAudioTrack(track.id) } + Panel.Audio -> { + val tracks = status.tracks.filter { it.type == "audio" } + items(tracks) { track -> + Choice( + track.label, + detail = listOf(if (track.external) "From the extension" else "In the video", track.details).filter { it.isNotEmpty() }.joinToString(" · "), + selected = track.selected, + ) { onAudioTrack(track.id) } + } + // Many sources carry one language each: say so, rather than leave an empty choice + if (tracks.size <= 1) item { + Text( + if (tracks.isEmpty()) "No audio track yet. It shows once the video starts." + else "This source has one audio track. Other sources may be in other languages: look in Source.", + style = MaterialTheme.typography.bodySmall, + color = PlayerColors.secondary, + modifier = Modifier.padding(16.dp), + ) + } } Panel.Speed -> items(speeds) { speed -> @@ -478,8 +760,9 @@ private fun SidePanel( /** Moves the subtitles earlier or later when they are out of sync with the speech */ @Composable private fun SubtitleTiming(delay: Double, onChange: (Double) -> Unit) { + val accent = accent() Column(Modifier.fillMaxWidth().padding(16.dp)) { - Text("Timing", style = MaterialTheme.typography.titleSmall) + Text("Timing", style = MaterialTheme.typography.titleSmall, color = PlayerColors.content) Text( when { abs(delay) < 0.05 -> "In sync with the video" @@ -487,12 +770,12 @@ private fun SubtitleTiming(delay: Double, onChange: (Double) -> Unit) { else -> "Shown %.1f s earlier".format(-delay) }, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = PlayerColors.secondary, ) Row(verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = { onChange(-0.5) }) { Text("Earlier") } - TextButton(onClick = { onChange(0.5) }) { Text("Later") } - if (abs(delay) >= 0.05) TextButton(onClick = { onChange(0.0) }) { Text("Reset") } + TextButton(onClick = { onChange(-0.5) }) { Text("Earlier", color = accent) } + TextButton(onClick = { onChange(0.5) }) { Text("Later", color = accent) } + if (abs(delay) >= 0.05) TextButton(onClick = { onChange(0.0) }) { Text("Reset", color = accent) } } } } @@ -506,22 +789,34 @@ private fun Choice( maxLines: Int = 2, onClick: () -> Unit, ) { - Column( + val accent = accent() + Row( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) - .padding(horizontal = 16.dp, vertical = 10.dp) + .background(if (selected) PlayerColors.selected else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = maxLines, overflow = TextOverflow.Ellipsis) - detail?.let { + Column(Modifier.weight(1f)) { Text( - it, - style = MaterialTheme.typography.bodySmall, - color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + text, + style = MaterialTheme.typography.bodyMedium, + color = if (selected) accent else PlayerColors.content, + fontWeight = if (selected) FontWeight.SemiBold else null, maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) + detail?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (error) MaterialTheme.colorScheme.error else PlayerColors.secondary, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } } + if (selected) Text("✓", color = accent, modifier = Modifier.padding(start = 8.dp)) } } diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_audiotrack_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_audiotrack_24.xml new file mode 100644 index 00000000000..6200ba23395 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_audiotrack_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_hd_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_hd_24.xml new file mode 100644 index 00000000000..da7d73ace4d --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_hd_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_replay_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_replay_24.xml new file mode 100644 index 00000000000..e247aa9243a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_replay_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/ic_baseline_subtitles_24.xml b/shared/src/commonMain/composeResources/drawable/ic_baseline_subtitles_24.xml new file mode 100644 index 00000000000..c5bfd344f81 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/ic_baseline_subtitles_24.xml @@ -0,0 +1,5 @@ + + + From 94bb90bd1e740de2b165bb7a762ffabe3c6db1af Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 18:46:21 +0530 Subject: [PATCH 15/64] Desktop: predictable player keys, full screen and back - The player's keys go through the window before anything else, so they work whatever has the focus. In full screen the bars hide with the button that had the focus, which left Space and the arrows doing nothing. The window's own shortcuts ran first too, so Escape with a panel open closed the player instead of the panel. - Escape closes an open panel, then leaves full screen, then goes back. K, J and L work like Space and the arrows. Keys that change something show the bars in full screen. - Real full screen on Windows: the window drops its title bar and border and covers the monitor, then gets them back. Compose's full screen kept the title bar. A maximized window is restored first and maximized again after, so it does not come back under the taskbar. - The mouse's back button goes back everywhere, the video included. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 23 +++- .../kotlin/com/lagradost/cloudstream4/Main.kt | 17 ++- .../cloudstream4/WindowsFullscreen.kt | 101 ++++++++++++++++++ .../com/lagradost/cloudstream4/player/Mpv.kt | 4 + .../cloudstream4/player/PlayerScreen.kt | 61 ++++++----- 5 files changed, 174 insertions(+), 32 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index e010af75786..a2957fb1390 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -25,7 +25,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType @@ -91,7 +95,8 @@ sealed class Page : ViewModelStoreOwner { /** * Navigation for the desktop app: a rail on the left and the selected destination on the right, * with pages such as details and the player opened on top. - * Ctrl+1 to Ctrl+4 switch destination, Ctrl+F opens Search, Escape or Alt+Left goes back. + * Ctrl+1 to Ctrl+4 switch destination, Ctrl+F opens Search, Escape, Alt+Left or the mouse's back + * button goes back. */ class AppNavigator { var destination by mutableStateOf(Destination.Home) @@ -106,6 +111,12 @@ class AppNavigator { /** The player fills the screen, without the rail or the window frame */ var fullscreen by mutableStateOf(false) + /** + * Keys the open player handles before the app's own, whatever has the focus: its buttons come + * and go, so focus cannot be relied on. Returns true for a key it used. + */ + var playerKeys: ((KeyEvent) -> Boolean)? = null + fun select(destination: Destination) { closePages() // Selecting Settings again returns to the settings home @@ -165,6 +176,7 @@ class AppNavigator { } fun onKeyEvent(event: KeyEvent): Boolean { + if (pages.lastOrNull() is Page.Player && playerKeys?.invoke(event) == true) return true if (event.type != KeyEventType.KeyDown) return false if (event.isCtrlPressed) { if (event.key == Key.F) { @@ -185,6 +197,7 @@ class AppNavigator { /** All providers the loaded plugins registered */ private fun loadedProviders() = APIHolder.apis.withLock { APIHolder.apis.toList() } +@OptIn(ExperimentalComposeUiApi::class) @Composable fun AppShell(navigator: AppNavigator) { val settings = rememberAppSettings() @@ -205,7 +218,12 @@ fun AppShell(navigator: AppNavigator) { val page = navigator.pages.lastOrNull() val hideRail = page is Page.Player && navigator.fullscreen - Row(Modifier.fillMaxSize()) { + Row( + Modifier + .fillMaxSize() + // The mouse's back button goes back, as in a browser + .onPointerEvent(PointerEventType.Press) { if (it.button == PointerButton.Back) navigator.back() }, + ) { if (!hideRail) NavigationRail( modifier = Modifier.fillMaxHeight(), containerColor = MaterialTheme.colorScheme.background, @@ -295,6 +313,7 @@ private fun PageContent(page: Page, navigator: AppNavigator) { fullscreen = navigator.fullscreen, onBack = { navigator.back() }, onToggleFullscreen = navigator::toggleFullscreen, + onKeys = { navigator.playerKeys = it }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 49a0f1d4f66..3a2f079c662 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -75,8 +75,11 @@ fun main() { val savedWindow = remember { SavedWindowState(desktopPreferences) } val windowState = rememberSavedWindowState(savedWindow) val navigator = remember { AppNavigator() } + var appWindow by remember { mutableStateOf(null) } Window( onCloseRequest = { + // So the window's normal size is saved, not the whole screen + appWindow?.let(WindowsFullscreen::exit) savedWindow.save(windowState) exitApplication() }, @@ -85,8 +88,9 @@ fun main() { icon = painterResource(Res.drawable.default_icon), onPreviewKeyEvent = navigator::onKeyEvent, ) { + appWindow = window MainContent(navigator) - FullscreenEffect(navigator, windowState) + FullscreenEffect(navigator, windowState, window) } } } @@ -129,12 +133,17 @@ private fun askToStartFresh(): Boolean { return fresh } -/** Puts the window in full screen while the player asks for it, then back to how it was */ +/** + * Puts the window in full screen while the player asks for it, then back to how it was. On + * Windows without the title bar, elsewhere with Compose's own full screen. + */ @Composable -private fun FullscreenEffect(navigator: AppNavigator, windowState: WindowState) { +private fun FullscreenEffect(navigator: AppNavigator, windowState: WindowState, window: java.awt.Window) { var before by remember { mutableStateOf(windowState.placement) } LaunchedEffect(navigator.fullscreen) { - if (navigator.fullscreen) { + if (WindowsFullscreen.supported) { + if (navigator.fullscreen) WindowsFullscreen.enter(window) else WindowsFullscreen.exit(window) + } else if (navigator.fullscreen) { before = windowState.placement windowState.placement = WindowPlacement.Fullscreen } else if (windowState.placement == WindowPlacement.Fullscreen) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt new file mode 100644 index 00000000000..ef933d5ae66 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt @@ -0,0 +1,101 @@ +package com.lagradost.cloudstream4 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.win32.StdCallLibrary +import java.awt.Window + +/** The user32 functions full screen needs, see winuser.h */ +@Suppress("FunctionName") +private interface User32 : StdCallLibrary { + fun GetWindowLongPtrW(hwnd: Pointer, index: Int): Long + fun SetWindowLongPtrW(hwnd: Pointer, index: Int, value: Long): Long + fun GetWindowRect(hwnd: Pointer, rect: Rect): Boolean + fun SetWindowPos(hwnd: Pointer, after: Pointer?, x: Int, y: Int, cx: Int, cy: Int, flags: Int): Boolean + fun MonitorFromWindow(hwnd: Pointer, flags: Int): Pointer? + fun GetMonitorInfoW(monitor: Pointer, info: MonitorInfo): Boolean + fun ShowWindow(hwnd: Pointer, cmd: Int): Boolean + fun IsZoomed(hwnd: Pointer): Boolean +} + +@Structure.FieldOrder("left", "top", "right", "bottom") +internal class Rect : Structure() { + @JvmField var left = 0 + @JvmField var top = 0 + @JvmField var right = 0 + @JvmField var bottom = 0 +} + +@Structure.FieldOrder("cbSize", "monitor", "work", "flags") +internal class MonitorInfo : Structure() { + @JvmField var cbSize = size() + @JvmField var monitor = Rect() + @JvmField var work = Rect() + @JvmField var flags = 0 +} + +/** + * Full screen the way browsers and video players do it on Windows: the window loses its title bar + * and border and covers the whole monitor, taskbar included. Compose's own full screen keeps the + * title bar of a decorated window. The window stays the same native window throughout, so the + * video playing in it carries on. + */ +object WindowsFullscreen { + private const val GWL_STYLE = -16 + private const val WS_CAPTION = 0x00C00000L + private const val WS_THICKFRAME = 0x00040000L + private const val MONITOR_DEFAULTTONEAREST = 2 + private const val SWP_NOZORDER = 0x0004 + private const val SWP_NOACTIVATE = 0x0010 + private const val SWP_FRAMECHANGED = 0x0020 + private const val SW_MAXIMIZE = 3 + private const val SW_RESTORE = 9 + + private val user32: User32? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Native.load("user32", User32::class.java) } + .onFailure { println("ERROR WindowsFullscreen: user32 could not be loaded: $it") } + .getOrNull() + } + + /** Whether this platform has it, otherwise Compose's own full screen is used */ + val supported: Boolean get() = user32 != null + + private class Saved(val style: Long, val rect: Rect, val maximized: Boolean) + + private var saved: Saved? = null + + val active: Boolean get() = saved != null + + fun enter(window: Window) { + val user32 = user32 ?: return + if (saved != null) return + val hwnd = Native.getWindowPointer(window) ?: return + // A maximized window is restored first: Windows keeps its maximized size otherwise, and on the + // way back the content would stay the size of the whole screen, under the taskbar + val maximized = user32.IsZoomed(hwnd) + if (maximized) user32.ShowWindow(hwnd, SW_RESTORE) + val rect = Rect().also { user32.GetWindowRect(hwnd, it) } + val style = user32.GetWindowLongPtrW(hwnd, GWL_STYLE) + val info = MonitorInfo() + val monitor = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) ?: return + if (!user32.GetMonitorInfoW(monitor, info)) return + saved = Saved(style, rect, maximized) + user32.SetWindowLongPtrW(hwnd, GWL_STYLE, style and (WS_CAPTION or WS_THICKFRAME).inv()) + val m = info.monitor + user32.SetWindowPos(hwnd, null, m.left, m.top, m.right - m.left, m.bottom - m.top, SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED) + } + + fun exit(window: Window) { + val user32 = user32 ?: return + val before = saved ?: return + saved = null + val hwnd = Native.getWindowPointer(window) ?: return + user32.SetWindowLongPtrW(hwnd, GWL_STYLE, before.style) + val r = before.rect + user32.SetWindowPos(hwnd, null, r.left, r.top, r.right - r.left, r.bottom - r.top, SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED) + // Maximizing again lets Windows size it to the screen without the taskbar, as before + if (before.maximized) user32.ShowWindow(hwnd, SW_MAXIMIZE) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index e2611fa6209..1054be062c5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -128,6 +128,8 @@ sealed interface MpvEvent { data object ToggleFullscreen : MpvEvent /** A single click inside the video */ data object Click : MpvEvent + /** The mouse's back button inside the video */ + data object Back : MpvEvent data object Ended : MpvEvent /** The file could not be played, for example a refused or broken link */ data class Failed(val url: String, val message: String) : MpvEvent @@ -188,6 +190,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { override fun mouseDragged(e: MouseEvent) = mouseMoved(e) override fun mouseClicked(e: MouseEvent) { + // The mouse's back button, as everywhere else in the app + if (e.button == 4) _events.trySend(MpvEvent.Back) if (e.button != MouseEvent.BUTTON1) return when { e.clickCount == 1 -> singleClick.restart() diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index f9225376fe4..3031723741a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -54,13 +53,14 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent @@ -105,8 +105,9 @@ private sealed interface SubtitleChoice { * Plays a movie or episode. The links load in the background and the best one starts on its own. * The bar on top shows the title and source; the bar below plays, seeks, sets the volume and opens * the subtitle, audio, speed and source pickers. In full screen the bars show while the mouse moves and hide after a few seconds. - * Space pauses, the arrows seek and change volume, M mutes, F toggles full screen and Escape closes - * an open panel or goes back. A click on the video closes an open panel, or pauses. + * Space or K pauses, the left and right arrows or J and L go 10 s back and forward, the up and down + * arrows change the volume, M mutes, F toggles full screen. Escape closes an open panel, then + * leaves full screen, then goes back. A click on the video closes an open panel, or pauses. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -115,6 +116,8 @@ fun PlayerScreen( fullscreen: Boolean, onBack: () -> Unit, onToggleFullscreen: () -> Unit, + /** Hands the player's key handler to the window, null when the player closes */ + onKeys: (((KeyEvent) -> Boolean)?) -> Unit = {}, ) { val lib = Mpv.library if (lib == null) { @@ -128,6 +131,7 @@ fun PlayerScreen( var subtitleMessage by remember { mutableStateOf(null) } var panel by remember { mutableStateOf(null) } val currentFullscreen by rememberUpdatedState(onToggleFullscreen) + val currentBack by rememberUpdatedState(onBack) // Extension subtitles downloaded so far, by url, and which one is shown val downloaded = remember { mutableStateMapOf() } @@ -189,6 +193,7 @@ fun PlayerScreen( player.events.collect { event -> when (event) { MpvEvent.ToggleFullscreen -> currentFullscreen() + MpvEvent.Back -> currentBack() MpvEvent.Click -> if (panel != null) panel = null else player.togglePause() is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(event.url, event.message)) MpvEvent.Ended -> { @@ -261,8 +266,29 @@ fun PlayerScreen( } } - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { focus.requestFocus() } + // Keys reach the player through the window, before anything focused: the buttons that could hold + // the focus hide in full screen, which used to leave the keys going nowhere + fun onKey(event: KeyEvent): Boolean { + if (event.type != KeyEventType.KeyDown || event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) return false + when (event.key) { + Key.Spacebar, Key.K -> player.togglePause() + Key.DirectionLeft, Key.J -> player.seek(-10.0) + Key.DirectionRight, Key.L -> player.seek(10.0) + Key.DirectionUp -> player.changeVolume(5) + Key.DirectionDown -> player.changeVolume(-5) + Key.M -> player.toggleMute() + Key.F -> currentFullscreen() + Key.Escape -> if (panel != null) panel = null else return false + else -> return false + } + // What the key changed shows in the bars, also in full screen + if (event.key != Key.Escape) activity = System.nanoTime() + return true + } + DisposableEffect(Unit) { + onKeys(::onKey) + onDispose { onKeys(null) } + } // Clicks on the bars outside their buttons close an open panel val closePanel = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { panel = null } Column( @@ -270,24 +296,7 @@ fun PlayerScreen( .fillMaxSize() .background(Color.Black) // Moves over the bars, which the video does not see - .onPointerEvent(PointerEventType.Move) { pointerMoved() } - .focusRequester(focus) - .focusable() - .onPreviewKeyEvent { - if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (it.key) { - Key.Spacebar -> player.togglePause() - Key.DirectionLeft -> player.seek(-10.0) - Key.DirectionRight -> player.seek(10.0) - Key.DirectionUp -> player.changeVolume(5) - Key.DirectionDown -> player.changeVolume(-5) - Key.M -> player.toggleMute() - Key.F -> onToggleFullscreen() - Key.Escape -> if (panel != null) panel = null else return@onPreviewKeyEvent false - else -> return@onPreviewKeyEvent false - } - true - }, + .onPointerEvent(PointerEventType.Move) { pointerMoved() }, ) { if (showBars) TopBar( title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), From 1472b48843a7975cbc4e986e0861185dd445e2df Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 2 Oct 2026 19:46:26 +0530 Subject: [PATCH 16/64] Desktop: M hides the app and pauses the video M pauses the player and hides the whole window, gone from the taskbar and Alt+Tab. A tray icon, shown only while hidden, brings it back as it was. Typing an M into a text field still types it: the text fields' input sessions are tracked, since their letters don't come as key events. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 113 +++++++++++------- .../kotlin/com/lagradost/cloudstream4/Main.kt | 39 ++++-- .../lagradost/cloudstream4/TypingTracker.kt | 27 +++++ .../cloudstream4/WindowsFullscreen.kt | 46 +++++-- .../com/lagradost/cloudstream4/player/Mpv.kt | 2 + .../cloudstream4/player/PlayerScreen.kt | 8 +- 6 files changed, 174 insertions(+), 61 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/TypingTracker.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index a2957fb1390..654793d14b5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.generated.resources.* @@ -96,7 +98,7 @@ sealed class Page : ViewModelStoreOwner { * Navigation for the desktop app: a rail on the left and the selected destination on the right, * with pages such as details and the player opened on top. * Ctrl+1 to Ctrl+4 switch destination, Ctrl+F opens Search, Escape, Alt+Left or the mouse's back - * button goes back. + * button goes back. M pauses the player and hides the app, see [hidden]. */ class AppNavigator { var destination by mutableStateOf(Destination.Home) @@ -175,6 +177,18 @@ class AppNavigator { return false } + /** + * The whole app is out of sight, the window gone from the screen, the taskbar and Alt+Tab, after + * M was pressed. The tray icon brings it back. + */ + var hidden by mutableStateOf(false) + + private fun isHideKey(event: KeyEvent) = event.type == KeyEventType.KeyDown && event.key == Key.M && + !event.isCtrlPressed && !event.isAltPressed && !event.isMetaPressed && !event.isShiftPressed + + /** A text field being typed in gets the M press too, which must not hide the app halfway through a word */ + val typing = TypingTracker() + fun onKeyEvent(event: KeyEvent): Boolean { if (pages.lastOrNull() is Page.Player && playerKeys?.invoke(event) == true) return true if (event.type != KeyEventType.KeyDown) return false @@ -192,6 +206,13 @@ class AppNavigator { if (event.key == Key.Escape || (event.isAltPressed && event.key == Key.DirectionLeft)) return back() return false } + + /** Keys nothing on the page used. The open player has paused on M already */ + fun onUnhandledKeyEvent(event: KeyEvent): Boolean { + if (!isHideKey(event) || typing.active) return false + hidden = true + return true + } } /** All providers the loaded plugins registered */ @@ -218,58 +239,60 @@ fun AppShell(navigator: AppNavigator) { val page = navigator.pages.lastOrNull() val hideRail = page is Page.Player && navigator.fullscreen - Row( - Modifier - .fillMaxSize() - // The mouse's back button goes back, as in a browser - .onPointerEvent(PointerEventType.Press) { if (it.button == PointerButton.Back) navigator.back() }, - ) { - if (!hideRail) NavigationRail( - modifier = Modifier.fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.background, + navigator.typing.Provide { + Row( + Modifier + .fillMaxSize() + // The mouse's back button goes back, as in a browser + .onPointerEvent(PointerEventType.Press) { if (it.button == PointerButton.Back) navigator.back() }, ) { - Column( + if (!hideRail) NavigationRail( modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + containerColor = MaterialTheme.colorScheme.background, ) { - Destination.entries.forEach { destination -> - val selected = navigator.destination == destination - NavigationRailItem( - selected = selected, - onClick = { navigator.select(destination) }, - icon = { - Icon( - painterResource(if (selected) destination.selectedIcon else destination.icon), - contentDescription = null, - ) - }, - label = { Text(stringResource(destination.title)) }, - ) + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + Destination.entries.forEach { destination -> + val selected = navigator.destination == destination + NavigationRailItem( + selected = selected, + onClick = { navigator.select(destination) }, + icon = { + Icon( + painterResource(if (selected) destination.selectedIcon else destination.icon), + contentDescription = null, + ) + }, + label = { Text(stringResource(destination.title)) }, + ) + } } } - } - if (!hideRail) VerticalDivider() - Box(Modifier.weight(1f).fillMaxHeight()) { - if (page != null) { - CompositionLocalProvider(LocalViewModelStoreOwner provides page) { - PageContent(page, navigator) - } - } else when (navigator.destination) { - Destination.Settings -> { - val settingsPage = navigator.settingsStack.lastOrNull() - ?: remember(navigator) { SettingsHomeScreen(navigator::open) } - val back: (() -> Unit)? = if (navigator.settingsStack.isEmpty()) null else ({ navigator.back() }) - CompositionLocalProvider(LocalBackPress provides back) { - settingsPage.Content() + if (!hideRail) VerticalDivider() + Box(Modifier.weight(1f).fillMaxHeight()) { + if (page != null) { + CompositionLocalProvider(LocalViewModelStoreOwner provides page) { + PageContent(page, navigator) + } + } else when (navigator.destination) { + Destination.Settings -> { + val settingsPage = navigator.settingsStack.lastOrNull() + ?: remember(navigator) { SettingsHomeScreen(navigator::open) } + val back: (() -> Unit)? = if (navigator.settingsStack.isEmpty()) null else ({ navigator.back() }) + CompositionLocalProvider(LocalBackPress provides back) { + settingsPage.Content() + } } - } - Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails) - Destination.Search -> SearchScreen(search, navigator::openExtensions, navigator::openDetails) - Destination.Library -> LibraryScreen(DataStoreWatchStore.instance, navigator::openDetails) - else -> ComingSoon(navigator.destination) + Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails) + Destination.Search -> SearchScreen(search, navigator::openExtensions, navigator::openDetails) + Destination.Library -> LibraryScreen(DataStoreWatchStore.instance, navigator::openDetails) + else -> ComingSoon(navigator.destination) + } + SnackbarHost(toasts, Modifier.align(Alignment.BottomCenter).padding(16.dp)) } - SnackbarHost(toasts, Modifier.align(Alignment.BottomCenter).padding(16.dp)) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 3a2f079c662..8318872931a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.window.DialogWindow import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import coil3.compose.setSingletonImageLoaderFactory @@ -76,22 +77,46 @@ fun main() { val windowState = rememberSavedWindowState(savedWindow) val navigator = remember { AppNavigator() } var appWindow by remember { mutableStateOf(null) } + val quit = { + // So the window's normal size is saved, not the whole screen + appWindow?.let(WindowsFullscreen::exit) + savedWindow.save(windowState) + exitApplication() + } + val icon = painterResource(Res.drawable.default_icon) Window( - onCloseRequest = { - // So the window's normal size is saved, not the whole screen - appWindow?.let(WindowsFullscreen::exit) - savedWindow.save(windowState) - exitApplication() - }, + onCloseRequest = quit, state = windowState, + visible = WindowsHide.supported || !navigator.hidden, title = stringResource(Res.string.app_name), - icon = painterResource(Res.drawable.default_icon), + icon = icon, onPreviewKeyEvent = navigator::onKeyEvent, + onKeyEvent = navigator::onUnhandledKeyEvent, ) { appWindow = window MainContent(navigator) FullscreenEffect(navigator, windowState, window) + LaunchedEffect(navigator.hidden) { + if (WindowsHide.supported) { + if (navigator.hidden) WindowsHide.hide(window) else WindowsHide.show(window) + } + if (navigator.hidden) return@LaunchedEffect + window.toFront() + // A window shown again doesn't hand the keys back to its content by itself, and the + // window itself taking them leaves every shortcut dead + (window.mostRecentFocusOwner ?: window.focusTraversalPolicy?.getDefaultComponent(window))?.requestFocus() + } } + // While the app is hidden, the only way back. Double click, or right click for the menu + if (navigator.hidden) Tray( + icon = icon, + tooltip = stringResource(Res.string.app_name), + onAction = { navigator.hidden = false }, + menu = { + Item("Show CloudStream", onClick = { navigator.hidden = false }) + Item("Quit", onClick = quit) + }, + ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/TypingTracker.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/TypingTracker.kt new file mode 100644 index 00000000000..ed278809fdf --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/TypingTracker.kt @@ -0,0 +1,27 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput + +/** + * Knows whether a text field is being typed in. The letters typed into one don't come as key events, + * so a key press can't tell: the text fields' input sessions can. Every text field below [content] + * opens its session through here, and the platform's input still does the work. + */ +class TypingTracker { + private var sessions = 0 + + val active: Boolean get() = sessions > 0 + + @OptIn(ExperimentalComposeUiApi::class) + @Composable + fun Provide(content: @Composable () -> Unit) = InterceptPlatformTextInput({ request, next -> + sessions++ + try { + next.startInputMethod(request) + } finally { + sessions-- + } + }, content) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt index ef933d5ae66..5a4668504c1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt @@ -17,6 +17,14 @@ private interface User32 : StdCallLibrary { fun GetMonitorInfoW(monitor: Pointer, info: MonitorInfo): Boolean fun ShowWindow(hwnd: Pointer, cmd: Int): Boolean fun IsZoomed(hwnd: Pointer): Boolean + fun SetForegroundWindow(hwnd: Pointer): Boolean +} + +private val user32: User32? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Native.load("user32", User32::class.java) } + .onFailure { println("ERROR User32: user32 could not be loaded: $it") } + .getOrNull() } @Structure.FieldOrder("left", "top", "right", "bottom") @@ -52,13 +60,6 @@ object WindowsFullscreen { private const val SW_MAXIMIZE = 3 private const val SW_RESTORE = 9 - private val user32: User32? by lazy { - if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null - runCatching { Native.load("user32", User32::class.java) } - .onFailure { println("ERROR WindowsFullscreen: user32 could not be loaded: $it") } - .getOrNull() - } - /** Whether this platform has it, otherwise Compose's own full screen is used */ val supported: Boolean get() = user32 != null @@ -99,3 +100,34 @@ object WindowsFullscreen { if (before.maximized) user32.ShowWindow(hwnd, SW_MAXIMIZE) } } + +/** + * Hides the whole window, gone from the screen, the taskbar and Alt+Tab, and shows it again as it + * was. Compose's own `visible` brings a maximized window back the size of the whole screen, with its + * bottom under the taskbar. + */ +object WindowsHide { + private const val SW_HIDE = 0 + private const val SW_SHOW = 5 + + val supported: Boolean get() = user32 != null + + var hidden = false + private set + + fun hide(window: Window) { + val user32 = user32 ?: return + val hwnd = Native.getWindowPointer(window) ?: return + user32.ShowWindow(hwnd, SW_HIDE) + hidden = true + } + + fun show(window: Window) { + val user32 = user32 ?: return + if (!hidden) return + hidden = false + val hwnd = Native.getWindowPointer(window) ?: return + user32.ShowWindow(hwnd, SW_SHOW) + user32.SetForegroundWindow(hwnd) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 1054be062c5..25989d2df4f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -347,6 +347,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun togglePause() = whenStarted { command("cycle", "pause") } + fun pause() = whenStarted { command("set", "pause", "yes") } + fun seek(seconds: Double) = whenStarted { command("seek", seconds.toString(), "relative") } fun seekTo(seconds: Double) = whenStarted { command("seek", seconds.toString(), "absolute") } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 3031723741a..5f01bbd3a1b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -106,7 +106,7 @@ private sealed interface SubtitleChoice { * The bar on top shows the title and source; the bar below plays, seeks, sets the volume and opens * the subtitle, audio, speed and source pickers. In full screen the bars show while the mouse moves and hide after a few seconds. * Space or K pauses, the left and right arrows or J and L go 10 s back and forward, the up and down - * arrows change the volume, M mutes, F toggles full screen. Escape closes an open panel, then + * arrows change the volume, F toggles full screen, M pauses and hides the app. Escape closes an open panel, then * leaves full screen, then goes back. A click on the video closes an open panel, or pauses. */ @OptIn(ExperimentalComposeUiApi::class) @@ -276,7 +276,11 @@ fun PlayerScreen( Key.DirectionRight, Key.L -> player.seek(10.0) Key.DirectionUp -> player.changeVolume(5) Key.DirectionDown -> player.changeVolume(-5) - Key.M -> player.toggleMute() + // The app hides at once, and the video must not carry on behind it: the window handles M next + Key.M -> { + player.pause() + return false + } Key.F -> currentFullscreen() Key.Escape -> if (panel != null) panel = null else return false else -> return false From 2dd14b43d62f94556d4464a0733e8e35916156fd Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 11:50:42 +0530 Subject: [PATCH 17/64] Desktop: subtitle settings and auto-select language Settings > Player gets the Android app's subtitle settings, in its keys (subtitle_settings, subs_auto_select): font, size, bold, italic, colors, edge, background box and distance from the bottom, with a live preview. The player applies them at once, through mpv's sub-* options, also to ASS subtitles as on Android. A Size control sits in the player's Subtitles panel. Auto-select: mpv picks the video's own track in the chosen language, and when there is none the first extension subtitle in it shows on its own. Also: picture subtitles (PGS, VobSub) say they keep their own look, codec names are readable, and subtitle labels no longer show a stray dot or miscount languages spelled with extra spaces. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 25 ++- .../cloudstream4/player/PlayerScreen.kt | 60 ++++- .../cloudstream4/settings/DesktopSettings.kt | 5 +- .../cloudstream4/settings/SubtitleSettings.kt | 211 ++++++++++++++++++ .../composeResources/values/strings.xml | 18 ++ .../cloudstream4/player/SubtitleSettings.kt | 165 ++++++++++++++ .../player/DataStoreSubtitleSettings.kt | 91 ++++++++ .../player/SubtitleSettingsTest.kt | 126 +++++++++++ 8 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/SubtitleSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/SubtitleSettings.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/DataStoreSubtitleSettings.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/player/SubtitleSettingsTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 25989d2df4f..b32e9b61e1a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -84,14 +84,27 @@ data class MpvTrack( val language: String? get() = lang?.let(::languageName) /** For example "Hindi", "Commentary · English" or "Track 2" */ - val label: String get() = listOfNotNull(title, language).distinctBy { it.lowercase() }.joinToString(" · ").ifEmpty { "Track $id" } + val label: String get() = listOfNotNull(title, language).filter { it.isNotBlank() }.distinctBy { it.lowercase() } + .joinToString(" · ").ifEmpty { "Track $id" } /** For example "5.1 · EAC3", what tells two tracks in one language apart */ val details: String get() = listOfNotNull( channels?.let { when (it) { 1 -> "Mono"; 2 -> "Stereo"; 6 -> "5.1"; 8 -> "7.1"; else -> "$it channels" } }, - codec?.uppercase(), + codec?.let { codecNames[it] ?: it.uppercase() }, "Default".takeIf { default }, + "Picture, keeps its own look".takeIf { isPicture }, ).joinToString(" · ") + + /** A subtitle drawn as pictures, as on Blu-rays and DVDs: the subtitle settings cannot change how it looks */ + val isPicture: Boolean get() = type == "sub" && codec in pictureSubtitles + + private companion object { + val pictureSubtitles = setOf("hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle", "xsub") + val codecNames = mapOf( + "hdmv_pgs_subtitle" to "PGS", "dvd_subtitle" to "VobSub", "dvb_subtitle" to "DVB", + "subrip" to "SRT", "webvtt" to "WebVTT", "mov_text" to "Text", + ) + } } /** "Hindi" for "hin" or "hi", the code itself when it is not a language */ @@ -361,6 +374,14 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun setSpeed(speed: Double) = whenStarted { command("set", "speed", speed.toString()) } + /** How subtitles look, from SubtitleStyle.mpvOptions. The one shown changes at once */ + fun setSubtitleStyle(options: List>) = whenStarted { + options.forEach { (name, value) -> command("set", name, value) } + } + + /** The languages, as codes, whose subtitle track in the video mpv shows on its own, for the next file */ + fun setSubtitleLanguages(codes: List) = whenStarted { command("set", "slang", codes.joinToString(",")) } + fun changeSubDelay(seconds: Double) = whenStarted { command("add", "sub-delay", seconds.toString()) } fun resetSubDelay() = whenStarted { command("set", "sub-delay", "0") } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 5f01bbd3a1b..b0cdb443db8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -118,6 +118,7 @@ fun PlayerScreen( onToggleFullscreen: () -> Unit, /** Hands the player's key handler to the window, null when the player closes */ onKeys: (((KeyEvent) -> Boolean)?) -> Unit = {}, + subtitleSettings: SubtitleSettingsStore = DataStoreSubtitleSettings.instance, ) { val lib = Mpv.library if (lib == null) { @@ -266,6 +267,29 @@ fun PlayerScreen( } } + // The subtitle settings apply at once, also while a subtitle shows + val subtitleStyle by subtitleSettings.style.collectAsState() + LaunchedEffect(player, subtitleStyle) { player.setSubtitleStyle(subtitleStyle.mpvOptions()) } + val autoSelect by subtitleSettings.autoSelect.collectAsState() + LaunchedEffect(player, autoSelect) { player.setSubtitleLanguages(autoSelect?.let(SubtitleLanguages::codesOf).orEmpty()) } + + // Auto-select, as on Android: once the video plays, if it shows no subtitle in the chosen language, + // the first from the extension in that language shows. Only until one is picked or turned off by hand + var autoSelected by remember { mutableStateOf(false) } + val started = status.duration > 0 + LaunchedEffect(autoSelect, state.subtitles, started) { + val tag = autoSelect ?: return@LaunchedEffect + if (autoSelected || !started || subtitleChoice != SubtitleChoice.Default) return@LaunchedEffect + if (status.tracks.any { it.type == "sub" && it.selected && SubtitleLanguages.tagOf(it.lang) == tag }) { + autoSelected = true + return@LaunchedEffect + } + val subtitle = state.subtitles.sortedBy { it.lang.lowercase() }.firstOrNull { SubtitleLanguages.tagOf(it.lang) == tag } + ?: return@LaunchedEffect + autoSelected = true + showSubtitle(subtitle) + } + // Keys reach the player through the window, before anything focused: the buttons that could hold // the focus hide in full screen, which used to leave the keys going nowhere fun onKey(event: KeyEvent): Boolean { @@ -334,6 +358,8 @@ fun PlayerScreen( player.selectSubtitle(id) }, onSubDelay = { if (it == 0.0) player.resetSubDelay() else player.changeSubDelay(it) }, + textSize = subtitleStyle.textSize, + onTextSize = { size -> subtitleSettings.setStyle(subtitleSettings.style.value.copy(fixedTextSize = size)) }, onAudioTrack = player::selectAudio, onSpeed = player::setSpeed, ) @@ -354,11 +380,14 @@ fun PlayerScreen( /** "English", or "English 1", "English 2" when there are several, by url */ private fun subtitleLabels(subtitles: List): Map { - val counts = subtitles.groupingBy { it.lang }.eachCount() + // Extensions spell a language with stray spaces at times, which must not make it a second language + fun name(sub: SubtitleFile) = sub.lang.trim().replace(Regex("\\s+"), " ") + val counts = subtitles.groupingBy(::name).eachCount() val seen = HashMap() return subtitles.associate { sub -> - val n = seen.merge(sub.lang, 1, Int::plus)!! - sub.url to if (counts.getValue(sub.lang) > 1) "${sub.lang} $n" else sub.lang + val lang = name(sub) + val n = seen.merge(lang, 1, Int::plus)!! + sub.url to if (counts.getValue(lang) > 1) "$lang $n" else lang } } @@ -701,6 +730,9 @@ private fun SidePanel( onSubtitle: (SubtitleFile) -> Unit, onSubtitleTrack: (Int?) -> Unit, onSubDelay: (Double) -> Unit, + textSize: Float, + /** null for the normal size */ + onTextSize: (Float?) -> Unit, onAudioTrack: (Int) -> Unit, onSpeed: (Double) -> Unit, ) { @@ -723,9 +755,10 @@ private fun SidePanel( val shown = tracks.firstOrNull { it.selected } // Timing first: a video can hold dozens of subtitle tracks if (shown != null) item { SubtitleTiming(status.subDelay, onSubDelay) } + if (shown != null) item { SubtitleSize(textSize, onTextSize) } item { Choice("Off", selected = shown == null) { onSubtitleTrack(null) } } // Sorted by language. Extensions often give several of one: they are numbered and show where they come from - val subtitles = state.subtitles.sortedBy { it.lang.lowercase() } + val subtitles = state.subtitles.sortedBy { it.lang.trim().lowercase() } val labels = subtitleLabels(subtitles) items(subtitles, key = { it.url }) { sub -> val track = downloaded[sub.url]?.let { file -> tracks.firstOrNull { it.isFile(file) } } @@ -793,6 +826,25 @@ private fun SubtitleTiming(delay: Double, onChange: (Double) -> Unit) { } } +/** The text size, kept for every video. The rest of the look is in Settings > Player */ +@Composable +private fun SubtitleSize(size: Float, onChange: (Float?) -> Unit) { + val accent = accent() + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text("Size", style = MaterialTheme.typography.titleSmall, color = PlayerColors.content) + Text( + if (size == SubtitleStyle.DEFAULT_TEXT_SIZE) "Normal · more in Settings > Player" else "${size.toInt()} · more in Settings > Player", + style = MaterialTheme.typography.bodySmall, + color = PlayerColors.secondary, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { onChange((size - 3).coerceAtLeast(10f)) }, enabled = size > 10f) { Text("Smaller", color = accent) } + TextButton(onClick = { onChange((size + 3).coerceAtMost(60f)) }, enabled = size < 60f) { Text("Larger", color = accent) } + if (size != SubtitleStyle.DEFAULT_TEXT_SIZE) TextButton(onClick = { onChange(null) }) { Text("Reset", color = accent) } + } + } +} + @Composable private fun Choice( text: String, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt index ba8d613174b..349a9a258b1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -31,8 +31,8 @@ import org.jetbrains.compose.resources.stringResource /* * Desktop versions of the settings screens in app/ui/settings. They use the same AppSettings keys, * but leave out what only applies to Android: TV layout, touch gestures, battery, rotation, PiP, - * APK updates and biometrics. Accounts, updates, backups, downloads and subtitles come with the - * phases that add those features. + * APK updates and biometrics. Accounts, updates, backups and downloads come with the phases that + * add those features. */ /** The settings home: one entry per category */ @@ -171,6 +171,7 @@ object SettingsPlayerScreen : SearchableSettings { ), ) ), + subtitlePreferences(), ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/SubtitleSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/SubtitleSettings.kt new file mode 100644 index 00000000000..e29c1bacf86 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/SubtitleSettings.kt @@ -0,0 +1,211 @@ +package com.lagradost.cloudstream4.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.platform.SystemFont +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.player.DataStoreSubtitleSettings +import com.lagradost.cloudstream4.player.SubtitleSettingsStore +import com.lagradost.cloudstream4.player.SubtitleStyle +import com.mihon.common.preference.PreferenceData +import com.mihon.presentation.settings.Preference +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +/** + * The subtitle settings, in the Android app's keys: how subtitles look and which language shows on + * its own. An open player applies a change right away. + */ +@Composable +fun subtitlePreferences(store: SubtitleSettingsStore = DataStoreSubtitleSettings.instance): Preference.PreferenceGroup { + val style by store.style.collectAsState() + val fields = remember(store) { SubtitleFields(store) } + val normal = stringResource(Res.string.normal) + val fonts = remember { installedFonts() } + + return Preference.PreferenceGroup( + title = stringResource(Res.string.subtitles_settings), + preferenceItems = persistentListOf( + Preference.PreferenceItem.CustomPreference(title = "") { SubtitlePreview(style, stringResource(Res.string.subtitles_example_text)) }, + Preference.PreferenceItem.ListPreference( + preference = fields.autoSelect, + entries = mapOf(null to stringResource(Res.string.none)) + + SubtitleHelper.languages.sortedBy { it.languageName }.associate { it.IETF_tag to it.languageName }, + title = stringResource(Res.string.subs_auto_select_language), + subtitle = "%s · shows on its own when a video has it", + icon = painterResource(Res.drawable.ic_baseline_subtitles_24), + ), + Preference.PreferenceItem.SliderPreference( + preference = fields.textSize, + title = stringResource(Res.string.subs_font_size), + valueRange = 10..60, + valueString = style.fixedTextSize?.toInt()?.toString() ?: "$normal (${SubtitleStyle.DEFAULT_TEXT_SIZE.toInt()})", + ), + Preference.PreferenceItem.ListPreference( + preference = fields.font, + // Fonts the Android app has but this computer lacks are left out, unless one is picked already + entries = mapOf(null to normal) + SubtitleStyle.fonts + .filter { (name, family) -> family in fonts || name == style.font } + .mapValues { (_, family) -> if (family in fonts) family else "$family (not installed)" }, + title = stringResource(Res.string.subs_font), + ), + Preference.PreferenceItem.SwitchPreference(preference = fields.bold, title = stringResource(Res.string.all_subtitles_bold)), + Preference.PreferenceItem.SwitchPreference(preference = fields.italic, title = stringResource(Res.string.all_subtitles_italic)), + Preference.PreferenceItem.ColorPreference( + preference = fields.textColor, + title = stringResource(Res.string.subs_text_color), + icon = painterResource(Res.drawable.colors_24px), + ), + Preference.PreferenceItem.ListPreference( + preference = fields.edgeType, + entries = mapOf( + SubtitleStyle.EDGE_NONE to stringResource(Res.string.none), + SubtitleStyle.EDGE_OUTLINE to stringResource(Res.string.subtitles_outline), + SubtitleStyle.EDGE_DROP_SHADOW to stringResource(Res.string.subtitles_shadow), + SubtitleStyle.EDGE_RAISED to stringResource(Res.string.subtitles_raised), + SubtitleStyle.EDGE_DEPRESSED to stringResource(Res.string.subtitles_depressed), + ), + title = stringResource(Res.string.subs_edge_type), + icon = painterResource(Res.drawable.format_paint_24px), + ), + Preference.PreferenceItem.ColorPreference(preference = fields.edgeColor, title = stringResource(Res.string.subs_outline_color)), + Preference.PreferenceItem.ColorPreference( + preference = fields.backgroundColor, + title = stringResource(Res.string.subs_background_color), + subtitle = "A box behind the text. Fully transparent for none", + icon = painterResource(Res.drawable.palette_24px), + ), + Preference.PreferenceItem.SliderPreference( + preference = fields.elevation, + title = stringResource(Res.string.subs_subtitle_elevation), + subtitle = "Distance from the bottom of the video", + valueRange = 0..100, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(Res.string.reset_btn), + subtitle = "Back to how subtitles look at first", + icon = painterResource(Res.drawable.ic_baseline_replay_24), + // The text changes only the Android app applies stay as they are + onClick = { + val now = store.style.value + store.setStyle(SubtitleStyle(removeCaptions = now.removeCaptions, removeBloat = now.removeBloat, upperCase = now.upperCase)) + }, + ), + ), + ) +} + +/** The fonts installed on this computer, by family */ +private fun installedFonts(): Set = runCatching { + java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames.toSet() +}.getOrDefault(emptySet()) + +/** Each setting as a preference the settings widgets can show and change */ +private class SubtitleFields(private val store: SubtitleSettingsStore) { + private fun field(key: String, read: (SubtitleStyle) -> T, write: SubtitleStyle.(T) -> SubtitleStyle) = + StorePreference(key, store.style, read, { store.setStyle(store.style.value.write(it)) }, read(SubtitleStyle())) + + val autoSelect = StorePreference("subs_auto_select", store.autoSelect, { it }, store::setAutoSelect, SubtitleSettingsStore.DEFAULT_AUTO_SELECT) + val textSize = field("fixedTextSize", { it.textSize.toInt() }) { copy(fixedTextSize = it.toFloat()) } + val font = field("font", { it.font }) { copy(font = it, typefaceFilePath = null) } + val bold = field("bold", { it.bold }) { copy(bold = it) } + val italic = field("italic", { it.italic }) { copy(italic = it) } + val textColor = field("foregroundColor", { it.foregroundColor }) { copy(foregroundColor = it) } + val edgeType = field("edgeType", { it.edgeType }) { copy(edgeType = it) } + val edgeColor = field("edgeColor", { it.edgeColor }) { copy(edgeColor = it) } + val backgroundColor = field("backgroundColor", { it.backgroundColor }) { copy(backgroundColor = it) } + val elevation = field("elevation", { it.elevation }) { copy(elevation = it) } +} + +/** A part of a value held in a [StateFlow], read and written as a preference */ +private class StorePreference( + private val key: String, + private val source: StateFlow, + private val read: (S) -> T, + private val write: (T) -> Unit, + private val default: T, +) : PreferenceData { + override fun key() = key + override fun get() = read(source.value) + override fun set(value: T) = write(value) + override fun isSet() = get() != default + override fun delete() = write(default) + override fun defaultValue() = default + override fun changes(): Flow = source.map(read).distinctUntilChanged() + override fun stateIn(scope: CoroutineScope): StateFlow = changes().stateIn(scope, SharingStarted.Eagerly, get()) +} + +/** The example text as the player shows it, over a dark frame */ +@OptIn(ExperimentalTextApi::class) +@Composable +private fun SubtitlePreview(style: SubtitleStyle, text: String) { + val family = style.font?.let { SubtitleStyle.fonts[it] }?.let { FontFamily(SystemFont(it)) } ?: FontFamily.SansSerif + val base = TextStyle( + fontSize = (style.textSize * 0.8f).sp, + fontFamily = family, + fontWeight = if (style.bold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (style.italic) FontStyle.Italic else FontStyle.Normal, + textAlign = TextAlign.Center, + ) + val edge = Color(style.edgeColor) + val shadow = when (style.edgeType) { + SubtitleStyle.EDGE_DROP_SHADOW -> Shadow(edge, Offset(3f, 3f), 2f) + SubtitleStyle.EDGE_RAISED -> Shadow(edge, Offset(1.5f, 1.5f), 0f) + SubtitleStyle.EDGE_DEPRESSED -> Shadow(edge, Offset(-1.5f, -1.5f), 0f) + else -> null + } + Box( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(140.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Brush.verticalGradient(listOf(Color(0xFF3A4A5E), Color(0xFF151A22)))), + ) { + Box( + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = (style.elevation * 0.6f).dp.coerceAtMost(100.dp), start = 16.dp, end = 16.dp) + .background(Color(style.backgroundColor).takeIf { it.alpha > 0f } ?: Color(style.windowColor)), + ) { + if (style.edgeType == SubtitleStyle.EDGE_OUTLINE) { + Text(text, style = base.copy(color = edge, drawStyle = Stroke(width = (style.edgeSize ?: 4f)))) + } + Text(text, style = base.copy(color = Color(style.foregroundColor), shadow = shadow)) + } + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 1a11146d58a..e8f19480b88 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -112,4 +112,22 @@ Video Skip OP Any legal issues regarding the content on this application should be taken up with the actual file hosts and providers themselves as we are not affiliated with them.\n\nIn case of copyright infringement, please directly contact the responsible parties or the streaming websites.\n\nThe app is purely for educational and personal use.\n\nCloudStream does not host any content on the app, and has no control over what media is put up or taken down. CloudStream functions like any other search engine, such as Google. CloudStream does not host, upload or manage any videos, films or content. It simply crawls, aggregates and displays links in a convenient, user-friendly interface.\n\nIt merely scrapes 3rd-party websites that are publicly accessible via any regular web browser. It is the responsibility of user to avoid any actions that might violate the laws governing his/her locality. Use CloudStream at your own risk. + Subtitle Settings + Text Color + Outline Color + Background Color + Edge Type + Subtitle Elevation + Font + Font Size + Auto-Select Language + Normal + Outline + Depressed + Shadow + Raised + The quick brown fox jumps over the lazy dog + Reset + Make all subtitles bold + Make all subtitles italic diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/SubtitleSettings.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/SubtitleSettings.kt new file mode 100644 index 00000000000..f26336dc2df --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/SubtitleSettings.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.utils.SubtitleHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlin.math.roundToInt + +/** + * How subtitles look: the Android app's SaveCaptionStyle, field for field, so both apps read the + * same setting. Colors are ARGB, sizes in Android's units: text in sp, edge in px, elevation in dp. + * The text changes (upper case, removing captions and bloat) are kept for Android but not applied here. + */ +data class SubtitleStyle( + val foregroundColor: Int = WHITE, + val backgroundColor: Int = TRANSPARENT, + val windowColor: Int = TRANSPARENT, + val edgeType: Int = EDGE_OUTLINE, + val edgeColor: Int = BLACK, + /** The Android app's SubtitleFont name, for example "Verdana" or "TimesNewRoman" */ + val font: String? = null, + val typefaceFilePath: String? = null, + val elevation: Int = DEFAULT_ELEVATION, + val fixedTextSize: Float? = null, + val edgeSize: Float? = null, + val removeCaptions: Boolean = false, + val removeBloat: Boolean = true, + val upperCase: Boolean = false, + val bold: Boolean = false, + val italic: Boolean = false, + val backgroundRadius: Float? = null, + val alignment: Int? = null, +) { + val textSize: Float get() = fixedTextSize ?: DEFAULT_TEXT_SIZE + + companion object { + const val WHITE = 0xFFFFFFFF.toInt() + const val BLACK = 0xFF000000.toInt() + const val TRANSPARENT = 0 + + // CaptionStyleCompat's edge types + const val EDGE_NONE = 0 + const val EDGE_OUTLINE = 1 + const val EDGE_DROP_SHADOW = 2 + const val EDGE_RAISED = 3 + const val EDGE_DEPRESSED = 4 + + /** The size Android uses when none is set, in sp */ + const val DEFAULT_TEXT_SIZE = 25f + const val DEFAULT_ELEVATION = 20 + + /** The Android app's subtitle fonts, by the name it saves, with the family to look for on desktop */ + val fonts = linkedMapOf( + "Trebuchet" to "Trebuchet MS", + "Netflix" to "Netflix Sans", + "Google" to "Google Sans", + "Open" to "Open Sans", + "Futura" to "Futura", + "Consola" to "Consolas", + "Gotham" to "Gotham", + "Lucida" to "Lucida Sans Unicode", + "STIX" to "STIX Two Text", + "TimesNewRoman" to "Times New Roman", + "Verdana" to "Verdana", + "Ubuntu" to "Ubuntu", + "Comic" to "Comic Sans MS", + "Poppins" to "Poppins", + ) + } +} + +/** + * mpv's options for a style. mpv sizes text against a 720 pixel tall video whatever its real size, + * so Android's sizes are scaled to look about the same on a phone and a monitor: its 25 sp default + * becomes mpv's own default of 38. Subtitles with styles of their own (ASS) take this one instead, + * as on Android: release groups often ship plain dialogue as ASS, which would otherwise ignore it. + */ +fun SubtitleStyle.mpvOptions(): List> { + val edge = (edgeSize ?: 4f) / 2 + val (outline, shadow) = when (edgeType) { + SubtitleStyle.EDGE_NONE -> 0f to 0f + SubtitleStyle.EDGE_DROP_SHADOW -> 0f to edge + SubtitleStyle.EDGE_RAISED, SubtitleStyle.EDGE_DEPRESSED -> edge / 2 to 1.5f + else -> edge to 0f + } + // Android draws the background behind the text and the window behind the whole cue: mpv has one box + val box = backgroundColor.takeIf { alpha(it) > 0 } ?: windowColor.takeIf { alpha(it) > 0 } + return listOf( + "sub-font" to (font?.let { SubtitleStyle.fonts[it] } ?: "sans-serif"), + "sub-font-size" to (textSize * 1.5f).roundToInt().toString(), + "sub-color" to mpvColor(foregroundColor), + "sub-bold" to yesNo(bold), + "sub-italic" to yesNo(italic), + "sub-border-style" to if (box != null) "background-box" else "outline-and-shadow", + "sub-back-color" to mpvColor(box ?: SubtitleStyle.TRANSPARENT), + "sub-outline-size" to outline.toString(), + "sub-outline-color" to mpvColor(edgeColor), + "sub-shadow-offset" to shadow.toString(), + "sub-shadow-color" to mpvColor(edgeColor), + "sub-margin-y" to (elevation * 1.7f).roundToInt().coerceAtLeast(0).toString(), + "sub-ass-override" to "force", + ) +} + +private fun alpha(argb: Int) = argb ushr 24 + +/** mpv reads #AARRGGBB */ +private fun mpvColor(argb: Int) = "#" + argb.toUInt().toString(16).padStart(8, '0').uppercase() + +private fun yesNo(value: Boolean) = if (value) "yes" else "no" + +/** Languages as the Android app names them: IETF tags such as "en" or "pt-BR" */ +object SubtitleLanguages { + /** A subtitle's language, from a name ("English", "English SDH") or a code ("en", "eng") */ + fun tagOf(language: String?): String? = SubtitleHelper.fromLanguageToTagIETF(language) + ?: SubtitleHelper.fromCodeToLangTagIETF(language) + ?: SubtitleHelper.fromLanguageToTagIETF(language, halfMatch = true) + + /** The codes a video file may use for a language, which mpv matches its tracks with */ + fun codesOf(tag: String): List { + val language = SubtitleHelper.languages.firstOrNull { it.IETF_tag == tag } ?: return listOf(tag) + return listOf(language.IETF_tag, language.ISO_639_1, language.ISO_639_2_B, language.ISO_639_3) + .filter { it.isNotEmpty() } + .distinct() + } +} + +/** + * The subtitle settings: how they look and the language picked on its own. The player applies a + * change right away. + */ +interface SubtitleSettingsStore { + val style: StateFlow + + fun setStyle(style: SubtitleStyle) + + /** The language whose subtitles show on their own, as an IETF tag, or null for none */ + val autoSelect: StateFlow + + fun setAutoSelect(tag: String?) + + companion object { + /** The Android app's default: English */ + const val DEFAULT_AUTO_SELECT = "en" + } +} + +class InMemorySubtitleSettings( + style: SubtitleStyle = SubtitleStyle(), + autoSelect: String? = SubtitleSettingsStore.DEFAULT_AUTO_SELECT, +) : SubtitleSettingsStore { + private val _style = MutableStateFlow(style) + override val style: StateFlow = _style.asStateFlow() + + override fun setStyle(style: SubtitleStyle) { + _style.value = style + } + + private val _autoSelect = MutableStateFlow(autoSelect) + override val autoSelect: StateFlow = _autoSelect.asStateFlow() + + override fun setAutoSelect(tag: String?) { + _autoSelect.value = tag + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/DataStoreSubtitleSettings.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/DataStoreSubtitleSettings.kt new file mode 100644 index 00000000000..b36a43edf64 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/DataStoreSubtitleSettings.kt @@ -0,0 +1,91 @@ +package com.lagradost.cloudstream4.player + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Subtitle settings in the Android app's keys and JSON: "subtitle_settings" for the style and + * "subs_auto_select" for the language, next to the library in the same store. + */ +class DataStoreSubtitleSettings( + private val context: Context = DesktopAndroid.application, +) : SubtitleSettingsStore { + /** The Android app's SaveCaptionStyle */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class SaveCaptionStyle( + @JsonProperty("foregroundColor") val foregroundColor: Int, + @JsonProperty("backgroundColor") val backgroundColor: Int, + @JsonProperty("windowColor") val windowColor: Int, + @JsonProperty("edgeType") val edgeType: Int, + @JsonProperty("edgeColor") val edgeColor: Int, + @JsonProperty("font") val font: String? = null, + @JsonProperty("typefaceFilePath") val typefaceFilePath: String?, + @JsonProperty("elevation") val elevation: Int, + @JsonProperty("fixedTextSize") val fixedTextSize: Float?, + @JsonProperty("edgeSize") val edgeSize: Float? = null, + @JsonProperty("removeCaptions") val removeCaptions: Boolean = false, + @JsonProperty("removeBloat") val removeBloat: Boolean = true, + @JsonProperty("upperCase") val upperCase: Boolean = false, + @JsonProperty("bold") val bold: Boolean = false, + @JsonProperty("italic") val italic: Boolean = false, + @JsonProperty("backgroundRadius") val backgroundRadius: Float? = null, + @JsonProperty("alignment") val alignment: Int? = null, + ) { + fun toStyle() = SubtitleStyle( + foregroundColor, backgroundColor, windowColor, edgeType, edgeColor, font, typefaceFilePath, elevation, + fixedTextSize, edgeSize, removeCaptions, removeBloat, upperCase, bold, italic, backgroundRadius, alignment, + ) + + companion object { + fun of(style: SubtitleStyle) = with(style) { + SaveCaptionStyle( + foregroundColor, backgroundColor, windowColor, edgeType, edgeColor, font, typefaceFilePath, elevation, + fixedTextSize, edgeSize, removeCaptions, removeBloat, upperCase, bold, italic, backgroundRadius, alignment, + ) + } + } + } + + private val _style = MutableStateFlow( + runCatching { context.getKey(STYLE, SaveCaptionStyle::class.java)?.toStyle() }.getOrNull() ?: SubtitleStyle() + ) + override val style: StateFlow = _style.asStateFlow() + + override fun setStyle(style: SubtitleStyle) { + context.setKey(STYLE, SaveCaptionStyle.of(style)) + _style.value = style + } + + // Android saves its "None" entry as the word itself, in the app's language: anything that is not a + // language counts as none + private val _autoSelect = MutableStateFlow( + context.getKey(AUTO_SELECT, String::class.java).let { saved -> + if (saved == null) SubtitleSettingsStore.DEFAULT_AUTO_SELECT + else saved.takeIf { tag -> SubtitleHelper.languages.any { it.IETF_tag == tag } } + } + ) + override val autoSelect: StateFlow = _autoSelect.asStateFlow() + + override fun setAutoSelect(tag: String?) { + context.setKey(AUTO_SELECT, tag ?: NONE) + _autoSelect.value = tag + } + + companion object { + private const val STYLE = "subtitle_settings" + private const val AUTO_SELECT = "subs_auto_select" + + /** What the Android app saves for no language, in English */ + private const val NONE = "None" + + val instance: DataStoreSubtitleSettings by lazy { DataStoreSubtitleSettings() } + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/player/SubtitleSettingsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/player/SubtitleSettingsTest.kt new file mode 100644 index 00000000000..f356acd97db --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/player/SubtitleSettingsTest.kt @@ -0,0 +1,126 @@ +package com.lagradost.cloudstream4.player + +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SubtitleSettingsTest { + private val dir = Files.createTempDirectory("cs-subtitles") + + init { + DesktopAndroid.init(dir) + } + + /** The saved preferences once they hold [text]: they are written to disk in the background */ + private fun savedWith(text: String): String { + val file = dir.resolve("shared_prefs/rebuild_preference.json") + fun read() = runCatching { file.readText() }.getOrDefault("") + val deadline = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < deadline && text !in read()) Thread.sleep(50) + return read() + } + + @AfterTest + fun cleanup() { + dir.toFile().deleteRecursively() + } + + @Test + fun savesInTheAndroidAppsKeysAndReadsBack() { + val settings = DataStoreSubtitleSettings() + assertEquals(SubtitleStyle(), settings.style.value) + assertEquals("en", settings.autoSelect.value) + + val style = SubtitleStyle(foregroundColor = 0xFFFFFF00.toInt(), font = "Verdana", fixedTextSize = 30f, bold = true) + settings.setStyle(style) + settings.setAutoSelect("hi") + + // A new store reads what the last one wrote, as after a restart + val again = DataStoreSubtitleSettings() + assertEquals(style, again.style.value) + assertEquals("hi", again.autoSelect.value) + + val file = savedWith("subs_auto_select") + assertTrue("subtitle_settings" in file && "fixedTextSize" in file && "foregroundColor" in file, file) + } + + @Test + fun readsWhatTheAndroidAppSaved() { + // As the Android app writes it: its font enum by name, sizes as floats + DesktopAndroid.application.setKey( + "subtitle_settings", + mapOf( + "foregroundColor" to -1, "backgroundColor" to 0, "windowColor" to 0, "edgeType" to 2, + "edgeColor" to -16777216, "font" to "Netflix", "typefaceFilePath" to null, "elevation" to 30, + "fixedTextSize" to 28.0, "edgeSize" to null, "removeCaptions" to true, "removeBloat" to true, + "upperCase" to false, "bold" to false, "italic" to true, "backgroundRadius" to null, "alignment" to null, + ), + ) + DesktopAndroid.application.setKey("subs_auto_select", "None") + + val settings = DataStoreSubtitleSettings() + val style = settings.style.value + assertEquals("Netflix", style.font) + assertEquals(SubtitleStyle.EDGE_DROP_SHADOW, style.edgeType) + assertEquals(28f, style.fixedTextSize) + assertEquals(30, style.elevation) + assertTrue(style.italic && style.removeCaptions) + assertNull(settings.autoSelect.value) + + // Writing keeps the fields the desktop doesn't use + settings.setStyle(style.copy(bold = true)) + assertTrue(DataStoreSubtitleSettings().style.value.removeCaptions) + } + + @Test + fun noneIsSavedTheWayAndroidSavesIt() { + val settings = DataStoreSubtitleSettings() + settings.setAutoSelect(null) + assertTrue("None" in savedWith("None"), "saved as the Android app saves it") + assertNull(DataStoreSubtitleSettings().autoSelect.value) + } + + @Test + fun mapsTheStyleToMpvOptions() { + val defaults = SubtitleStyle().mpvOptions().toMap() + assertEquals("38", defaults["sub-font-size"]) + assertEquals("#FFFFFFFF", defaults["sub-color"]) + assertEquals("outline-and-shadow", defaults["sub-border-style"]) + assertEquals("2.0", defaults["sub-outline-size"]) + assertEquals("0.0", defaults["sub-shadow-offset"]) + assertEquals("sans-serif", defaults["sub-font"]) + // Also on subtitles with styles of their own, as on Android + assertEquals("force", defaults["sub-ass-override"]) + + val boxed = SubtitleStyle( + backgroundColor = 0x80000000.toInt(), + edgeType = SubtitleStyle.EDGE_DROP_SHADOW, + font = "TimesNewRoman", + fixedTextSize = 40f, + italic = true, + ).mpvOptions().toMap() + assertEquals("background-box", boxed["sub-border-style"]) + assertEquals("#80000000", boxed["sub-back-color"]) + assertEquals("0.0", boxed["sub-outline-size"]) + assertEquals("2.0", boxed["sub-shadow-offset"]) + assertEquals("Times New Roman", boxed["sub-font"]) + assertEquals("60", boxed["sub-font-size"]) + assertEquals("yes", boxed["sub-italic"]) + } + + @Test + fun findsTheLanguageOfNamesAndCodes() { + assertEquals("en", SubtitleLanguages.tagOf("English")) + assertEquals("en", SubtitleLanguages.tagOf("eng")) + assertEquals("en", SubtitleLanguages.tagOf("English SDH")) + assertEquals("hi", SubtitleLanguages.tagOf("hin")) + assertNull(SubtitleLanguages.tagOf(null)) + assertEquals(listOf("en", "eng"), SubtitleLanguages.codesOf("en")) + } +} From 3aa189f72f74d94df0a14b459774f32fc80f9621 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 12:41:01 +0530 Subject: [PATCH 18/64] Desktop: lower memory use and one shared HTTP client Memory: the heap is capped at 1 GB and gives unused memory back to Windows when idle (it may grow to a quarter of RAM otherwise); the poster memory cache is a fixed 96 MB instead of 15% of the heap; mpv keeps 96 MB ahead and 24 MB behind instead of 150 and 50. Network: extensions, posters, subtitles, repositories and updates share one OkHttp client (DesktopHttp) with one connection pool and a 50 MB HTTP cache, as on Android, instead of six clients of their own. Settings > General gets the Android app's DNS over HTTPS choice. mpv reconnects dropped streams, gives up on a dead one after 30 s and skips the youtube-dl lookup. The log file is buffered. Co-Authored-By: Claude Opus 5.5 (1M context) --- desktopApp/build.gradle.kts | 11 ++ .../com/lagradost/cloudstream4/DesktopLog.kt | 5 +- .../kotlin/com/lagradost/cloudstream4/Main.kt | 4 + .../lagradost/cloudstream4/browse/Posters.kt | 8 +- .../com/lagradost/cloudstream4/player/Mpv.kt | 10 ++ .../cloudstream4/player/Subtitles.kt | 4 +- .../cloudstream4/settings/DesktopSettings.kt | 7 ++ gradle/libs.versions.toml | 1 + shared/build.gradle.kts | 5 + .../composeResources/values/strings.xml | 2 + .../cloudstream4/network/DesktopHttp.kt | 118 ++++++++++++++++++ .../cloudstream4/plugins/RepositoryClient.kt | 4 +- .../cloudstream4/update/AppUpdater.kt | 3 +- .../cloudstream4/network/DesktopHttpTest.kt | 62 +++++++++ 14 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/network/DesktopHttp.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/network/DesktopHttpTest.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index a78e9cb4513..031897eea48 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -102,6 +102,17 @@ compose.desktop { mainClass = "com.lagradost.cloudstream4.MainKt" // Read by AppVersion, for the update check jvmArgs("-Dcloudstream.version=${libs.versions.versionName.get()}") + // Memory. Without a limit the heap may grow to a quarter of the computer's RAM before it is + // collected, while the app uses well under 100 MB of it. When idle, the heap shrinks and the + // memory goes back to Windows. Strings repeated across extensions' results are kept once + jvmArgs( + "-Xmx1g", + "-XX:+UseG1GC", + "-XX:G1PeriodicGCInterval=60000", + "-XX:MinHeapFreeRatio=10", + "-XX:MaxHeapFreeRatio=30", + "-XX:+UseStringDeduplication", + ) nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "CloudStream" diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt index c4336c817d9..7a582bcaf1f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream4 +import java.io.BufferedOutputStream import java.io.FileOutputStream import java.io.OutputStream import java.io.PrintStream @@ -23,7 +24,9 @@ object DesktopLog { val file = dir.resolve("cloudstream.log") if (file.exists()) Files.move(file, dir.resolve("cloudstream.old.log"), REPLACE_EXISTING) - val log = TimestampedOutputStream(FileOutputStream(file.toFile())) + // Buffered: the timestamps are added a byte at a time, which would otherwise mean a write to + // disk per byte. Both streams flush at the end of every line, so the log stays current + val log = TimestampedOutputStream(BufferedOutputStream(FileOutputStream(file.toFile()))) System.setOut(PrintStream(TeeOutputStream(System.out, log), true, Charsets.UTF_8)) System.setErr(PrintStream(TeeOutputStream(System.err, log), true, Charsets.UTF_8)) } catch (t: Throwable) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 8318872931a..4eb6d2d06e0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -35,6 +35,7 @@ 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.android.DesktopAndroid +import com.lagradost.cloudstream4.network.DesktopHttp import com.lagradost.cloudstream4.plugins.DesktopPluginManager import com.lagradost.cloudstream4.settings.AppUpdatePrompt import com.lagradost.cloudstream4.settings.autoUpdateExtensions @@ -58,6 +59,9 @@ fun main() { DesktopLog.install(AppDirs.logs) // Extensions and the library keep their data in the Android data store, see DataStoreWatchStore DesktopAndroid.init(AppDirs.root.resolve("android")) + // Before the extensions load: they make their requests through it + @OptIn(DelicateCoroutinesApi::class) + DesktopHttp.start(GlobalScope) // Plugins load in the background so a slow or broken plugin never delays the window @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt index 238bfa4a181..2601784ee2f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -38,6 +38,7 @@ import coil3.memory.MemoryCache import coil3.network.NetworkHeaders import coil3.network.httpHeaders import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.lagradost.cloudstream4.network.DesktopHttp import coil3.request.ImageRequest import coil3.request.crossfade import coil3.serviceLoaderEnabled @@ -52,7 +53,9 @@ import okio.Path.Companion.toOkioPath */ fun desktopImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) .crossfade(200) - .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.15).build() } + // A fixed size: a share of the heap limit would grow with it, and decoded posters live outside the + // heap. 96 MB holds a few hundred posters at the size they are shown + .memoryCache { MemoryCache.Builder().maxSizeBytes(96L * 1024 * 1024).build() } .diskCache { DiskCache.Builder() .directory(AppDirs.cache.resolve("images").toOkioPath()) @@ -61,7 +64,8 @@ fun desktopImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Buil } // Only this fetcher, so the Ktor one that the shared module brings in is never picked instead .serviceLoaderEnabled(false) - .components { add(OkHttpNetworkFetcherFactory()) } + // The app's connections and DNS, without its HTTP cache: posters have the disk cache above + .components { add(OkHttpNetworkFetcherFactory(callFactory = { DesktopHttp.uncached })) } .build() private val verticalWidth = 140.dp diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index b32e9b61e1a..6dc68bd141b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -238,6 +238,16 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { option("force-window", "yes") option("sub-auto", "no") option("drag-and-drop", "no") + // Memory: mpv reads up to 150 MB ahead and keeps 50 MB behind by default. This is still about a + // minute ahead of a 1080p stream, and enough behind to skip back without loading again + option("demuxer-max-bytes", "96MiB") + option("demuxer-max-back-bytes", "24MiB") + // Network: a stream that stops answering fails after 30 s instead of 60, so the next source can + // play, and a dropped connection is opened again where it stopped + option("network-timeout", "30") + option("stream-lavf-o", "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5") + // Links come from the extensions already: no youtube-dl lookup before every file + option("ytdl", "no") check("initialize", lib.mpv_initialize(ctx)) eventThread = Thread(::eventLoop, "mpv-events").apply { isDaemon = true; start() } pending.forEach { it() } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt index f22bdb03655..35bec4cd27d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt @@ -5,7 +5,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream4.AppDirs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient +import com.lagradost.cloudstream4.network.DesktopHttp import okhttp3.Request import java.io.File import java.io.IOException @@ -16,7 +16,7 @@ import java.security.MessageDigest * its own headers, which mpv could only send by changing the headers of the video too. */ object Subtitles { - private val http = OkHttpClient() + private val http get() = DesktopHttp.client private val dir: File get() = AppDirs.cache.resolve("subtitles").toFile() suspend fun download(subtitle: SubtitleFile): File = withContext(Dispatchers.IO) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt index 349a9a258b1..25f031f5a79 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -19,6 +19,7 @@ import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.network.DesktopHttp import com.lagradost.cloudstream4.rememberAppSettings import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor import com.lagradost.cloudstream4.theme.modeToTheme @@ -101,6 +102,12 @@ object SettingsGeneralScreen : SearchableSettings { ), ) ), + Preference.PreferenceItem.ListPreference( + preference = settings.general.dns, + entries = DesktopHttp.dnsProviders.mapValues { (value, name) -> if (value == 0) stringResource(Res.string.none) else name }, + title = stringResource(Res.string.dns_pref), + subtitle = "%s · " + stringResource(Res.string.dns_pref_summary), + ), Preference.PreferenceItem.TextPreference( title = stringResource(Res.string.benene), subtitle = if (bananas == 0) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f0dfaf60a1..b5a91067bd9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -144,6 +144,7 @@ nextlib-media3ext = { module = "io.github.anilbeesetti:nextlib-media3ext", versi nextlib-mediainfo = { module = "io.github.anilbeesetti:nextlib-mediainfo", version.ref = "nextlibMedia3" } nicehttp = { module = "com.github.Blatzar:NiceHttp", version.ref = "nicehttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" } overlappingpanels = { module = "com.github.discord:OverlappingPanels", version.ref = "overlappingpanels" } palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 6ab7286ba35..bb34aa31165 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -55,6 +55,9 @@ kotlin { implementation(libs.ktor.client.java) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) + implementation(libs.okhttp.dnsoverhttps) + // For the extensions' client, app.baseClient + implementation(libs.nicehttp) } jvmTest.dependencies { @@ -76,4 +79,6 @@ compose.resources { // The Android extensions test converts and loads hundreds of extensions in one run tasks.withType().configureEach { maxHeapSize = "3g" + // Tests keep their files, such as the HTTP cache, away from the real app's folders + systemProperty("cloudstream.home", layout.buildDirectory.dir("test-home").get().asFile.absolutePath) } diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index e8f19480b88..1d5361024e6 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -130,4 +130,6 @@ Reset Make all subtitles bold Make all subtitles italic + DNS over HTTPS + Useful for bypassing ISP blocks diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/network/DesktopHttp.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/network/DesktopHttp.kt new file mode 100644 index 00000000000..98aded16cc1 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/network/DesktopHttp.kt @@ -0,0 +1,118 @@ +package com.lagradost.cloudstream4.network + +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.insecureApp +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.settings +import com.lagradost.nicehttp.ignoreAllSSLErrors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import okhttp3.Cache +import okhttp3.Call +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import java.io.File +import java.net.InetAddress +import java.util.concurrent.TimeUnit + +/** + * The one HTTP client of the app. Extensions (through `app`), posters, subtitles, repositories and + * updates all go through it, so they share one set of open connections and threads instead of each + * keeping its own, and repeated requests are answered from a disk cache when the server allows it. + * Built like the Android app's buildDefaultClient, with its DNS over HTTPS choices. + */ +object DesktopHttp { + /** The Android app's dns_key values: 0 is the system's DNS */ + val dnsProviders: Map = linkedMapOf( + 0 to "None", 1 to "Google", 2 to "Cloudflare", 4 to "AdGuard", 5 to "DNS.WATCH", + 6 to "Quad9", 7 to "DNS.SB", 8 to "Canadian Shield", + ) + + private val pool = ConnectionPool(maxIdleConnections = 10, keepAliveDuration = 2, timeUnit = TimeUnit.MINUTES) + private val dispatcher = Dispatcher() + + /** Where responses are cached, changed by tests only */ + @Volatile var cacheDir: File = AppDirs.cache.resolve("http").toFile() + + // One cache for every client built here: two caches on one folder would corrupt it + private val cache by lazy { Cache(cacheDir, CACHE_BYTES) } + + @Volatile private var dns = 0 + + @Volatile private var current: OkHttpClient? = null + + /** The client, with the DNS picked in the settings */ + val client: OkHttpClient + get() = current ?: synchronized(this) { current ?: build(dns).also { current = it } } + + /** + * Sets the DNS and makes the extensions use the client. Called at start and whenever the DNS + * setting changes, as the Android app does. + */ + fun install(dns: Int) { + synchronized(this) { + this.dns = dns + current = build(dns) + } + app.baseClient = client + // For the few extensions whose sites have broken certificates, as on Android + insecureApp.baseClient = client.newBuilder().ignoreAllSSLErrors().build() + } + + @Volatile private var uncachedClient: Pair? = null + + /** + * Calls through the client without its cache, for loaders that keep a cache of their own, such as + * Coil. It follows DNS changes too + */ + val uncached: Call.Factory = Call.Factory { request -> + val base = client + val made = uncachedClient?.takeIf { it.first === base }?.second + ?: base.newBuilder().cache(null).build().also { uncachedClient = base to it } + made.newCall(request) + } + + /** Uses the DNS from the settings now and whenever it changes */ + fun start(scope: CoroutineScope) { + val dns = settings.general.dns + install(dns.get()) + scope.launch { dns.changes().drop(1).collect(::install) } + } + + private fun build(dns: Int): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectionPool(pool) + .dispatcher(dispatcher) + .cache(cache) + .followRedirects(true) + .followSslRedirects(true) + val (url, ips) = dohServers[dns] ?: return builder.build() + // The resolver's own lookups go through a client without it, by its fixed addresses + val bootstrap = builder.build() + return builder.dns( + DnsOverHttps.Builder() + .client(bootstrap) + .url(url.toHttpUrl()) + .bootstrapDnsHosts(ips.map { InetAddress.getByName(it) }) + .build() + ).build() + } + + /** The Android app's DohProviders */ + private val dohServers = mapOf( + 1 to ("https://dns.google/dns-query" to listOf("8.8.4.4", "8.8.8.8")), + 2 to ("https://cloudflare-dns.com/dns-query" to listOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")), + 4 to ("https://dns.adguard.com/dns-query" to listOf("94.140.14.140", "94.140.14.141")), + 5 to ("https://resolver2.dns.watch/dns-query" to listOf("84.200.69.80", "84.200.70.40")), + 6 to ("https://dns.quad9.net/dns-query" to listOf("9.9.9.9", "149.112.112.112")), + 7 to ("https://doh.dns.sb/dns-query" to listOf("185.222.222.222", "45.11.45.11")), + 8 to ("https://private.canadianshield.cira.ca/dns-query" to listOf("149.112.121.10", "149.112.122.10")), + ) + + /** As on Android */ + private const val CACHE_BYTES = 50L * 1024 * 1024 +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt index 50fc739cc71..665847f8eb4 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt @@ -11,6 +11,7 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.longOrNull +import com.lagradost.cloudstream4.network.DesktopHttp import okhttp3.OkHttpClient import okhttp3.Request import java.security.MessageDigest @@ -65,7 +66,8 @@ class PluginDownloadException(message: String) : Exception(message) * GitHub address (seen on some ISPs) made every download time out. */ class RepositoryClient( - private val http: OkHttpClient = OkHttpClient.Builder() + // The app's client, so repository lists come from its cache while GitHub says they are fresh + private val http: OkHttpClient = DesktopHttp.client.newBuilder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(), diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt index 37a52d67fad..a8755555b16 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +import com.lagradost.cloudstream4.network.DesktopHttp import okhttp3.OkHttpClient import okhttp3.Request import java.nio.file.Files @@ -37,7 +38,7 @@ class AppUpdateException(message: String) : Exception(message) */ class AppUpdater( private val releasesUrl: String = LATEST_RELEASE, - private val http: OkHttpClient = OkHttpClient.Builder() + private val http: OkHttpClient = DesktopHttp.client.newBuilder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .build(), diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/network/DesktopHttpTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/network/DesktopHttpTest.kt new file mode 100644 index 00000000000..9f521f83b78 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/network/DesktopHttpTest.kt @@ -0,0 +1,62 @@ +package com.lagradost.cloudstream4.network + +import com.lagradost.cloudstream3.app +import com.sun.net.httpserver.HttpServer +import okhttp3.Request +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DesktopHttpTest { + private val hits = AtomicInteger() + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/fresh") { exchange -> + hits.incrementAndGet() + val body = "repo list".toByteArray() + exchange.responseHeaders.add("Cache-Control", "max-age=300") + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + start() + } + private val base = "http://127.0.0.1:${server.address.port}" + + @AfterTest + fun stop() = server.stop(0) + + private fun get(path: String) = DesktopHttp.client.newCall(Request.Builder().url(base + path).build()).execute().use { it.body.string() } + + @Test + fun answersRepeatedRequestsFromItsCacheWhileTheServerSaysTheyAreFresh() { + assertEquals("repo list", get("/fresh")) + assertEquals("repo list", get("/fresh")) + assertEquals(1, hits.get(), "the second request came from the cache") + } + + @Test + fun theExtensionsUseIt() { + DesktopHttp.install(0) + assertSame(DesktopHttp.client, app.baseClient) + } + + @Test + fun everyDnsChoiceBuildsAClient() { + for (dns in DesktopHttp.dnsProviders.keys) { + DesktopHttp.install(dns) + assertSame(DesktopHttp.client, app.baseClient) + assertTrue(DesktopHttp.client.cache != null) + } + DesktopHttp.install(0) + } + + @Test + fun theUncachedCallsSkipTheCache() { + DesktopHttp.uncached.newCall(Request.Builder().url("$base/fresh").build()).execute().close() + DesktopHttp.uncached.newCall(Request.Builder().url("$base/fresh").build()).execute().close() + assertEquals(2, hits.get()) + } +} From b0281a00fd0bd21acd0c71ba3bcba23f5d6244a7 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 13:23:13 +0530 Subject: [PATCH 19/64] Fix Episode.addDate reading dates with no offset as UTC toInstantUsingOffset treats a missing offset as UTC, so a custom format with a time but no offset (e.g. "yyyy-MM-dd'T'HH:mm:ss") skipped the device timezone fallback. Only use the offset when one was parsed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index ca024b463a1..8977e1d7ef5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -2598,7 +2598,8 @@ fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") { * 2. If it has time but no offset (e.g. "2026-05-17 14:35"), fall back to device timezone * 3. If it's date-only (e.g. "2026-05-17"), use start of day in device timezone */ - runCatching { components.toInstantUsingOffset().toEpochMilliseconds() } + // toInstantUsingOffset reads a missing offset as UTC, so only use it when one was parsed + runCatching { components.also { requireNotNull(it.offsetHours) }.toInstantUsingOffset().toEpochMilliseconds() } .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() } .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() } } From bfa3821b4cc155d7a08a8178bccb9bb1e62f8945 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 13:41:07 +0530 Subject: [PATCH 20/64] Desktop: download movies and episodes to play offline Adds a Downloads page and download buttons on the title page. Plain videos resume with byte ranges; HLS streams are joined segment by segment with M3u8Helper, as on Android. Data is kept in the Android app's keys (download_header_cache, download_episode_cache, download_info). Subtitles in the auto-select language are saved beside the video. A downloaded file plays without finding links, also when its extension is not loaded. The download folder is set in Settings > General. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 20 +- .../cloudstream4/browse/DetailScreen.kt | 61 +++- .../cloudstream4/browse/DownloadsScreen.kt | 290 ++++++++++++++++ .../cloudstream4/player/PlayerScreen.kt | 6 +- .../cloudstream4/player/Subtitles.kt | 2 + .../cloudstream4/settings/DesktopSettings.kt | 20 ++ .../composeResources/drawable/delete_24px.xml | 10 + .../drawable/download_24px.xml | 11 + .../drawable/downloading_24px.xml | 5 + .../drawable/folder_open_24px.xml | 12 + .../composeResources/values/strings.xml | 3 + .../cloudstream4/detail/DetailViewModel.kt | 5 + .../cloudstream4/player/LinksViewModel.kt | 18 +- .../cloudstream4/download/DownloadNames.kt | 33 ++ .../cloudstream4/download/DownloadStore.kt | 169 ++++++++++ .../cloudstream4/download/Downloader.kt | 145 ++++++++ .../cloudstream4/download/Downloads.kt | 315 ++++++++++++++++++ .../cloudstream4/download/DownloadsTest.kt | 163 +++++++++ 18 files changed, 1278 insertions(+), 10 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt create mode 100644 shared/src/commonMain/composeResources/drawable/delete_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/download_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/downloading_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/folder_open_24px.xml create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadNames.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloader.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 654793d14b5..9e921164343 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -48,6 +48,8 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.lagradost.cloudstream4.android.DesktopAndroid import com.lagradost.cloudstream4.browse.DetailScreen +import com.lagradost.cloudstream4.browse.DownloadsScreen +import com.lagradost.cloudstream4.download.Downloads import com.lagradost.cloudstream4.browse.HomeScreen import com.lagradost.cloudstream4.browse.LibraryScreen import com.lagradost.cloudstream4.browse.Message @@ -83,6 +85,7 @@ enum class Destination( Home(Res.string.title_home, Res.drawable.home_icon_outline_24, Res.drawable.home_icon_filled_24), Search(Res.string.title_search, Res.drawable.search_icon, Res.drawable.search_icon), Library(Res.string.library, Res.drawable.library_icon, Res.drawable.library_icon_filled), + Downloads(Res.string.title_downloads, Res.drawable.download_24px, Res.drawable.download_24px), Settings(Res.string.title_settings, Res.drawable.settings_icon_outline, Res.drawable.settings_icon_filled), } @@ -97,7 +100,7 @@ sealed class Page : ViewModelStoreOwner { /** * Navigation for the desktop app: a rail on the left and the selected destination on the right, * with pages such as details and the player opened on top. - * Ctrl+1 to Ctrl+4 switch destination, Ctrl+F opens Search, Escape, Alt+Left or the mouse's back + * Ctrl+1 to Ctrl+5 switch destination, Ctrl+F opens Search, Escape, Alt+Left or the mouse's back * button goes back. M pauses the player and hides the app, see [hidden]. */ class AppNavigator { @@ -197,7 +200,7 @@ class AppNavigator { select(Destination.Search) return true } - val index = listOf(Key.One, Key.Two, Key.Three, Key.Four).indexOf(event.key) + val index = listOf(Key.One, Key.Two, Key.Three, Key.Four, Key.Five).indexOf(event.key) if (index >= 0) { select(Destination.entries[index]) return true @@ -289,6 +292,7 @@ fun AppShell(navigator: AppNavigator) { Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails) Destination.Search -> SearchScreen(search, navigator::openExtensions, navigator::openDetails) Destination.Library -> LibraryScreen(DataStoreWatchStore.instance, navigator::openDetails) + Destination.Downloads -> DownloadsScreen(Downloads.instance, navigator::play, navigator::openDetails) else -> ComingSoon(navigator.destination) } SnackbarHost(toasts, Modifier.align(Alignment.BottomCenter).padding(16.dp)) @@ -317,8 +321,18 @@ private fun PageContent(page: Page, navigator: AppNavigator) { is Page.Details -> page.apiName is Page.Player -> page.request.apiName } - // The provider can be gone if its plugin was uninstalled while the page was open + // The provider can be gone if its plugin was uninstalled while the page was open. A download plays without it val api = remember(apiName) { APIHolder.getApiFromNameNull(apiName) } + if (page is Page.Player && page.request.localFile != null) { + PlayerScreen( + viewModel = viewModel { LinksViewModel(api, page.request) }, + fullscreen = navigator.fullscreen, + onBack = { navigator.back() }, + onToggleFullscreen = navigator::toggleFullscreen, + onKeys = { navigator.playerKeys = it }, + ) + return + } if (api == null) { Message("$apiName is not loaded", "Its extension may have been uninstalled.") return diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index cea9fa9ae6a..54c2f4e5364 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -44,6 +44,12 @@ import androidx.compose.ui.unit.dp import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.Episode import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream4.download.DownloadRequest +import com.lagradost.cloudstream4.download.DownloadSource +import com.lagradost.cloudstream4.download.DownloadedEpisode +import com.lagradost.cloudstream4.download.DownloadedTitle +import com.lagradost.cloudstream4.download.Downloads import com.lagradost.cloudstream4.detail.DetailAction import com.lagradost.cloudstream4.detail.DetailState import com.lagradost.cloudstream4.detail.DetailStatus @@ -63,6 +69,7 @@ fun DetailScreen( onBack: () -> Unit, onPlay: (PlayRequest) -> Unit, onOpen: (apiName: String, url: String) -> Unit, + downloads: Downloads = Downloads.instance, ) { val state by viewModel.state.collectAsState() Column(Modifier.fillMaxSize()) { @@ -75,7 +82,7 @@ fun DetailScreen( Button(onClick = { viewModel.onAction(DetailAction.Retry) }) { Text("Try again") } } - DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen) + DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, downloads) } } } @@ -86,17 +93,38 @@ private fun Details( viewModel: DetailViewModel, onPlay: (PlayRequest) -> Unit, onOpen: (apiName: String, url: String) -> Unit, + downloads: Downloads, ) { val response = state.response ?: return val season = state.seasons.getOrNull(state.selectedSeason) + val downloadStates by downloads.states.collectAsState() + // A downloaded movie or episode plays its file, without finding links + fun play(request: PlayRequest?, episode: Episode?) { + request ?: return + val id = downloadId(state, episode) + val file = id?.let(downloads::file) + onPlay(if (file == null) request else request.copy(localFile = file.absolutePath, localSubtitles = localSubtitles(downloads, id))) + } + fun downloadButton(episode: Episode?): @Composable () -> Unit = { + val id = downloadId(state, episode) + if (id != null) DownloadButton( + state = downloadStates[id], + onStart = { downloadRequest(state, episode)?.let(downloads::start) }, + onPause = { downloads.pause(id) }, + onResume = { downloadRequest(state, episode)?.let(downloads::start) }, + onDelete = { state.titleId?.let { downloads.delete(it, id) } }, + onPlay = { play(viewModel.playRequest(episode), episode) }, + ) + } LazyColumn(contentPadding = PaddingValues(bottom = 32.dp), modifier = Modifier.fillMaxSize()) { item { Header( response = response, state = state, - onPlay = { fromStart -> viewModel.playRequest(fromStart = fromStart)?.let(onPlay) }, - onResume = { episode -> viewModel.playRequest(episode)?.let(onPlay) }, + onPlay = { fromStart -> play(viewModel.playRequest(fromStart = fromStart), null) }, + onResume = { episode -> play(viewModel.playRequest(episode), episode) }, onBookmark = { viewModel.onAction(DetailAction.SetBookmark(it)) }, + download = if (state.movieData != null && response.type != TvType.Live) downloadButton(null) else null, ) } @@ -113,7 +141,7 @@ private fun Details( ) } items(season.episodes) { episode -> - EpisodeRow(episode, state.progressOf(episode)) { viewModel.playRequest(episode)?.let(onPlay) } + EpisodeRow(episode, state.progressOf(episode), downloadButton(episode)) { play(viewModel.playRequest(episode), episode) } HorizontalDivider(Modifier.padding(horizontal = 24.dp)) } } @@ -145,6 +173,8 @@ private fun Header( onPlay: (fromStart: Boolean) -> Unit, onResume: (com.lagradost.cloudstream3.Episode) -> Unit, onBookmark: (WatchType) -> Unit, + /** The movie's download button, null for a series or a live stream */ + download: (@Composable () -> Unit)?, ) { Row(Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp)) { RemoteImage( @@ -196,6 +226,7 @@ private fun Header( } } LibraryButton(state.bookmark, onBookmark) + download?.invoke() } } } @@ -256,7 +287,7 @@ private fun LibraryButton(current: WatchType, onSelect: (WatchType) -> Unit) { } @Composable -private fun EpisodeRow(episode: Episode, progress: PlaybackPosition?, onClick: () -> Unit) { +private fun EpisodeRow(episode: Episode, progress: PlaybackPosition?, download: @Composable () -> Unit, onClick: () -> Unit) { Row( Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -284,5 +315,25 @@ private fun EpisodeRow(episode: Episode, progress: PlaybackPosition?, onClick: ( progress != null && progress.positionMs > 0 -> Text("${formatTime(progress.positionMs)} / ${formatTime(progress.durationMs)}", style = MaterialTheme.typography.bodySmall) else -> episode.runTime?.let { Text("$it min", style = MaterialTheme.typography.bodySmall) } } + download() } } + +/** The id a download is saved under: the episode's, or the title's for a movie */ +private fun downloadId(state: DetailState, episode: Episode?): Int? = + if (episode == null) state.titleId else state.episodeIds[episode] + +/** What downloads an episode, or the movie when [episode] is null */ +private fun downloadRequest(state: DetailState, episode: Episode?): DownloadRequest? { + val response = state.response ?: return null + val titleId = state.titleId ?: return null + val id = downloadId(state, episode) ?: return null + val data = episode?.data ?: state.movieData ?: return null + // An episode without a number is numbered by its place, so two never get the same file name + val number = episode?.let { it.episode ?: (state.seasons.flatMap { season -> season.episodes }.indexOf(it) + 1) } + return DownloadRequest( + DownloadedTitle(titleId, response.apiName, response.url, response.name, response.type, response.posterUrl), + DownloadedEpisode(id, titleId, episode?.name, number, episode?.season, episode?.posterUrl ?: response.posterUrl, episode?.description), + DownloadSource(response.apiName, data), + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt new file mode 100644 index 00000000000..0300dd2f276 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt @@ -0,0 +1,290 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream4.detail.PlayRequest +import com.lagradost.cloudstream4.download.DownloadState +import com.lagradost.cloudstream4.download.DownloadStatus +import com.lagradost.cloudstream4.download.DownloadedEpisode +import com.lagradost.cloudstream4.download.DownloadedTitle +import com.lagradost.cloudstream4.download.Downloads +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.delete_24px +import com.lagradost.cloudstream4.generated.resources.download_24px +import com.lagradost.cloudstream4.generated.resources.downloads_empty +import com.lagradost.cloudstream4.generated.resources.downloading_24px +import com.lagradost.cloudstream4.generated.resources.error +import com.lagradost.cloudstream4.generated.resources.folder_open_24px +import com.lagradost.cloudstream4.generated.resources.pause_24px +import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.library.PlaybackTracking +import com.lagradost.cloudstream4.library.TitleHeader +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import java.awt.Desktop +import java.io.File + +/** Everything downloaded or downloading, by title, with each file's progress and what can be done with it */ +@Composable +fun DownloadsScreen( + downloads: Downloads, + onPlay: (PlayRequest) -> Unit, + openDetails: (apiName: String, url: String) -> Unit, +) { + val states by downloads.states.collectAsState() + // The list changes when a download starts or is deleted, not with every progress update + val ids = states.keys + val titles = remember(ids) { downloads.titles().map { it to downloads.episodes(it.id) } } + val folder = downloads.folder + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(start = 24.dp, top = 24.dp, end = 24.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Downloads", style = MaterialTheme.typography.headlineSmall) + val used = states.values.sumOf { it.bytes } + Text( + listOfNotNull(folder.absolutePath, used.takeIf { it > 0 }?.let { "${formatBytes(it)} used" }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton(onClick = { openFolder(folder) }) { + Icon(painterResource(Res.drawable.folder_open_24px), contentDescription = null, modifier = Modifier.size(18.dp)) + Text("Open folder", modifier = Modifier.padding(start = 8.dp)) + } + } + if (titles.isEmpty()) { + Message(stringResource(Res.string.downloads_empty), "Use the download button on a movie or an episode to watch it offline.") + return@Column + } + LazyColumn(contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + titles.forEach { (title, episodes) -> + item(key = "title-${title.id}") { + TitleRow(title, episodes.size, episodes.sumOf { states[it.id]?.bytes ?: 0 }) { openDetails(title.apiName, title.url) } + } + items(episodes, key = { "episode-${it.id}" }) { episode -> + val state = states[episode.id] ?: return@items + EpisodeDownloadRow( + label = downloadLabel(title, episode), + state = state, + onPlay = { downloadedPlayRequest(downloads, title, episode)?.let(onPlay) }, + onPause = { downloads.pause(episode.id) }, + onResume = if (downloads.canResume(title.id, episode.id)) ({ downloads.resume(title.id, episode.id) }) else null, + onDelete = { downloads.delete(title.id, episode.id) }, + ) + } + item(key = "divider-${title.id}") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + } + } + } +} + +@Composable +private fun TitleRow(title: DownloadedTitle, count: Int, bytes: Long, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RemoteImage(title.poster, null, contentDescription = title.name, modifier = Modifier.width(60.dp).height(90.dp)) + Column(Modifier.weight(1f)) { + Text(title.name, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + val files = if (count == 1) "1 file" else "$count files" + Text( + listOf(title.type.name, title.apiName, files, formatBytes(bytes)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EpisodeDownloadRow( + label: String, + state: DownloadState, + onPlay: () -> Unit, + onPause: () -> Unit, + onResume: (() -> Unit)?, + onDelete: () -> Unit, +) { + val done = state.status == DownloadStatus.Done + Row( + Modifier.fillMaxWidth().clickable(enabled = done, onClick = onPlay).padding(start = 76.dp, top = 6.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + statusText(state), + style = MaterialTheme.typography.bodySmall, + color = if (state.status == DownloadStatus.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (state.active || state.status == DownloadStatus.Paused) { + val fraction = state.fraction + if (fraction != null) LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f)) + else if (state.active) LinearProgressIndicator(modifier = Modifier.fillMaxWidth(0.6f)) + } + } + when { + done -> IconButton(onClick = onPlay) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = "Play") } + state.active -> IconButton(onClick = onPause) { Icon(painterResource(Res.drawable.pause_24px), contentDescription = "Pause") } + onResume != null -> IconButton(onClick = onResume) { Icon(painterResource(Res.drawable.downloading_24px), contentDescription = "Resume") } + } + IconButton(onClick = onDelete) { Icon(painterResource(Res.drawable.delete_24px), contentDescription = "Delete") } + } +} + +/** + * The download button of a movie or an episode: it starts the download, then shows its progress. + * A click while it runs pauses it; once paused, failed or done, a menu offers what can be done next. + */ +@Composable +fun DownloadButton( + state: DownloadState?, + onStart: () -> Unit, + onPause: () -> Unit, + onResume: () -> Unit, + onDelete: () -> Unit, + onPlay: () -> Unit, +) { + var menu by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { + when { + state == null -> onStart() + state.active -> onPause() + else -> menu = true + } + }) { + when { + state == null -> Icon(painterResource(Res.drawable.download_24px), contentDescription = "Download") + state.active -> Box(contentAlignment = Alignment.Center) { + val fraction = state.fraction + if (fraction != null && state.status == DownloadStatus.Downloading) { + CircularProgressIndicator(progress = { fraction }, modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + } else { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp) + } + Icon(painterResource(Res.drawable.pause_24px), contentDescription = "Pause download", modifier = Modifier.size(14.dp)) + } + state.status == DownloadStatus.Done -> Icon( + painterResource(Res.drawable.download_24px), + contentDescription = "Downloaded", + tint = MaterialTheme.colorScheme.primary, + ) + state.status == DownloadStatus.Failed -> Icon(painterResource(Res.drawable.error), contentDescription = "Download failed", tint = MaterialTheme.colorScheme.error) + else -> Icon(painterResource(Res.drawable.downloading_24px), contentDescription = "Download paused") + } + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + state?.let { Text(statusText(it), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) } + if (state?.status == DownloadStatus.Done) DropdownMenuItem(text = { Text("Play downloaded file") }, onClick = { menu = false; onPlay() }) + if (state?.status == DownloadStatus.Paused || state?.status == DownloadStatus.Failed) { + DropdownMenuItem(text = { Text(if (state.status == DownloadStatus.Failed) "Try again" else "Resume") }, onClick = { menu = false; onResume() }) + } + DropdownMenuItem(text = { Text("Delete download") }, onClick = { menu = false; onDelete() }) + } + } +} + +/** For example "Downloading · 120 MB of 800 MB · 15%" */ +fun statusText(state: DownloadState): String { + val sizes = when { + state.total > 0 && state.status != DownloadStatus.Done -> "${formatBytes(state.bytes)} of ${formatBytes(state.total)}" + state.bytes > 0 -> formatBytes(state.bytes) + else -> null + } + val percent = state.fraction?.takeIf { state.status != DownloadStatus.Done }?.let { "${(it * 100).toInt()}%" } + return when (state.status) { + DownloadStatus.Queued -> "Waiting for another download to finish" + DownloadStatus.FindingLinks -> "Finding links…" + DownloadStatus.Downloading -> listOfNotNull("Downloading", sizes, percent).joinToString(" · ") + DownloadStatus.Paused -> listOfNotNull("Paused", sizes, percent).joinToString(" · ") + DownloadStatus.Failed -> "Failed: ${state.message ?: "unknown error"}" + DownloadStatus.Done -> listOfNotNull("Downloaded", sizes).joinToString(" · ") + } +} + +fun formatBytes(bytes: Long): String = when { + bytes >= 1L shl 30 -> "%.1f GB".format(bytes / (1L shl 30).toDouble()) + bytes >= 1L shl 20 -> "%d MB".format(bytes / (1L shl 20)) + else -> "%d KB".format(bytes / 1024) +} + +/** "Movie", or "S1 E2 · Pilot" */ +private fun downloadLabel(title: DownloadedTitle, episode: DownloadedEpisode): String { + if (episode.episode == null) return title.name + val number = listOfNotNull(episode.season?.let { "S$it" }, "E${episode.episode}").joinToString(" ") + return listOf(number, episode.name.orEmpty().trim()).filter { it.isNotEmpty() }.joinToString(" · ") +} + +/** + * Plays a download from the Downloads page, where the title's page may not open (offline, or the + * extension gone). Its progress is saved as if it played from the title's page. + */ +fun downloadedPlayRequest(downloads: Downloads, title: DownloadedTitle, episode: DownloadedEpisode): PlayRequest? { + val file = downloads.file(episode.id) ?: return null + val header = TitleHeader(title.id, title.apiName, title.url, title.name, title.type, title.poster) + val done = downloads.episodes(title.id).filter { downloads.file(it.id) != null } + val next = done.getOrNull(done.indexOfFirst { it.id == episode.id } + 1)?.takeIf { episode.episode != null } + val position = DataStoreWatchStore.instance.position(episode.id)?.takeUnless { it.watched }?.positionMs ?: 0 + val tracking = PlaybackTracking( + header = header, + id = episode.id, + episode = episode.episode, + season = episode.season, + next = next?.let { PlaybackTracking.NextEpisode(it.id, it.episode, it.season) }, + startPositionMs = position, + ) + val label = if (episode.episode == null) null else downloadLabel(title, episode) + return PlayRequest(title.apiName, title.name, label, "", tracking, file.absolutePath, localSubtitles(downloads, episode.id)) +} + +/** The subtitles saved beside a download, for the player */ +fun localSubtitles(downloads: Downloads, id: Int): List = + downloads.subtitles(id).map { (language, file) -> SubtitleFile(language, file.absolutePath) } + +private fun openFolder(folder: File) { + runCatching { + folder.mkdirs() + Desktop.getDesktop().open(folder) + }.onFailure { println("WARN DownloadsScreen: could not open $folder: $it") } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index b0cdb443db8..e1e61a64121 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -764,7 +764,11 @@ private fun SidePanel( val track = downloaded[sub.url]?.let { file -> tracks.firstOrNull { it.isFile(file) } } Choice( labels.getValue(sub.url), - detail = if (loadingSubtitle == sub.url) "Loading…" else listOfNotNull("From the extension", host(sub.url)).joinToString(" · "), + detail = when { + loadingSubtitle == sub.url -> "Loading…" + !sub.url.contains("://") -> "Saved with the download" + else -> listOfNotNull("From the extension", host(sub.url)).joinToString(" · ") + }, selected = track?.selected == true, ) { onSubtitle(sub) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt index 35bec4cd27d..b9b6f7170fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Subtitles.kt @@ -20,6 +20,8 @@ object Subtitles { private val dir: File get() = AppDirs.cache.resolve("subtitles").toFile() suspend fun download(subtitle: SubtitleFile): File = withContext(Dispatchers.IO) { + // A subtitle saved beside a download is a file already + File(subtitle.url).takeIf { !subtitle.url.contains("://") && it.isFile }?.let { return@withContext it } val name = MessageDigest.getInstance("SHA-1").digest(subtitle.url.toByteArray()).joinToString("") { "%02x".format(it) } // mpv reads the format from the content, the extension only helps when it is known val extension = subtitle.url.substringBefore('?').substringAfterLast('.', "").lowercase() diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt index 25f031f5a79..9a666d8a274 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter @@ -19,6 +21,7 @@ import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.download.Downloads import com.lagradost.cloudstream4.network.DesktopHttp import com.lagradost.cloudstream4.rememberAppSettings import com.lagradost.cloudstream4.theme.CloudStreamPrimaryColor @@ -68,6 +71,7 @@ object SettingsGeneralScreen : SearchableSettings { val settings = rememberAppSettings() val uriHandler = LocalUriHandler.current val bananas = settings.general.bananas.get() + val downloadPath by settings.general.downloadPath.changes().collectAsState(settings.general.downloadPath.get()) fun link(title: String, url: String, icon: Painter) = Preference.PreferenceItem.TextPreference( title = title, @@ -108,6 +112,12 @@ object SettingsGeneralScreen : SearchableSettings { title = stringResource(Res.string.dns_pref), subtitle = "%s · " + stringResource(Res.string.dns_pref_summary), ), + Preference.PreferenceItem.TextPreference( + title = stringResource(Res.string.download_path_pref), + subtitle = downloadPath.ifBlank { Downloads.defaultRoot().absolutePath }, + icon = painterResource(Res.drawable.folder_open_24px), + onClick = { chooseFolder(downloadPath.ifBlank { Downloads.defaultRoot().absolutePath })?.let { settings.general.downloadPath.set(it) } }, + ), Preference.PreferenceItem.TextPreference( title = stringResource(Res.string.benene), subtitle = if (bananas == 0) { @@ -432,3 +442,13 @@ private fun dubSubPreference() = Preference.PreferenceItem.MultiSelectListPrefer DubStatus.Subbed.name to stringResource(Res.string.app_subbed_text), ) ) + +/** The system's folder picker, starting in [current]. Null when cancelled */ +private fun chooseFolder(current: String): String? { + val chooser = javax.swing.JFileChooser(java.io.File(current).takeIf { it.isDirectory } ?: java.io.File(System.getProperty("user.home"))).apply { + dialogTitle = "Download path" + fileSelectionMode = javax.swing.JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + } + return if (chooser.showDialog(null, "Use this folder") == javax.swing.JFileChooser.APPROVE_OPTION) chooser.selectedFile?.absolutePath else null +} diff --git a/shared/src/commonMain/composeResources/drawable/delete_24px.xml b/shared/src/commonMain/composeResources/drawable/delete_24px.xml new file mode 100644 index 00000000000..c0b119490b3 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/delete_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/download_24px.xml b/shared/src/commonMain/composeResources/drawable/download_24px.xml new file mode 100644 index 00000000000..6a5a08bf3c1 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/download_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/downloading_24px.xml b/shared/src/commonMain/composeResources/drawable/downloading_24px.xml new file mode 100644 index 00000000000..de1f4faca02 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/downloading_24px.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/folder_open_24px.xml b/shared/src/commonMain/composeResources/drawable/folder_open_24px.xml new file mode 100644 index 00000000000..06e0e968fcb --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/folder_open_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 1d5361024e6..7f65e0471b0 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -132,4 +132,7 @@ Make all subtitles italic DNS over HTTPS Useful for bypassing ISP blocks + Downloads + There are currently no downloads. + Download path diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt index 770a5aa7a89..076bc5ca1e7 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt @@ -10,6 +10,7 @@ import com.lagradost.cloudstream3.LiveStreamLoadResponse import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.TorrentLoadResponse import com.lagradost.cloudstream3.TvSeriesLoadResponse import com.lagradost.cloudstream3.mvvm.Resource @@ -37,6 +38,10 @@ data class PlayRequest( val data: String, /** Where to save progress, null when nothing is tracked */ val tracking: PlaybackTracking? = null, + /** A downloaded file to play instead of finding links */ + val localFile: String? = null, + /** Subtitles saved beside [localFile], their url a file path */ + val localSubtitles: List = emptyList(), ) /** Where Continue watching left off: an episode, or the movie when [episode] is null */ diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt index 6692fb10720..5d3759e2ac4 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt @@ -51,7 +51,8 @@ sealed interface LinksAction { * moves on to the next best one. */ class LinksViewModel( - private val api: MainAPI, + /** Null when only a downloaded file plays and its extension is not loaded */ + private val api: MainAPI?, val request: PlayRequest, private val autoPickAfter: Duration = 6.seconds, ) : ViewModel(), StateContainer by DefaultStateContainer(LinksState()), ActionHandler { @@ -81,6 +82,18 @@ class LinksViewModel( private fun load() { loadJob?.cancel() autoPicking = false + val local = request.localFile + if (local != null) { + // A download plays at once, with the subtitles saved beside it + @Suppress("DEPRECATION") + val link = ExtractorLink(LOCAL_SOURCE, LOCAL_SOURCE, local, "", Qualities.Unknown.value, type = ExtractorLinkType.VIDEO) + updateState { LinksState(links = listOf(link), subtitles = request.localSubtitles, loading = false, selected = local) } + return + } + if (api == null) { + updateState { LinksState(loading = false) } + return + } updateState { LinksState() } loadJob = viewModelScope.launch { val picker = launch { @@ -121,6 +134,9 @@ class LinksViewModel( } companion object { + /** The source name of a downloaded file */ + const val LOCAL_SOURCE = "Downloaded" + private fun nextLink(links: List, failed: Map) = links.firstOrNull { it.url !in failed }?.url diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadNames.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadNames.kt new file mode 100644 index 00000000000..cb4af66350c --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadNames.kt @@ -0,0 +1,33 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased + +/** The Android app's download file and folder names, from DownloadFileManagement, in English */ +object DownloadNames { + private val reserved = Regex("""[|\\?*<":>+\[\]/']""") + + /** Characters Windows does not allow in a name become spaces, as on Android */ + fun sanitize(name: String): String = + name.replace(reserved, " ").replace(Regex("""\p{Cntrl}"""), " ").replace(Regex(" {2,}"), " ").trim() + // Windows drops a trailing dot, which would make the file impossible to find again + .trimEnd('.', ' ') + + /** "Movies", or "TVSeries/" for a type with episodes */ + fun folder(type: TvType, title: String): String { + val prefix = type.getFolderPrefix() + return if (type.isEpisodeBased()) "$prefix/${sanitize(title)}" else prefix + } + + /** + * The file name without its extension: the title for a movie, "Season 1 Episode 2 - Pilot", + * "Episode 2 - Pilot", "Season 1 Episode 2" or "Episode 2" for an episode + */ + fun fileName(title: String, episode: Int?, season: Int?, episodeName: String?): String { + if (episode == null) return sanitize(title) + val number = listOfNotNull(season?.let { "Season $it" }, "Episode $episode").joinToString(" ") + val name = episodeName?.takeIf { it.isNotBlank() } + return sanitize(if (name != null) "$number - $name" else number) + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt new file mode 100644 index 00000000000..3f2ca84c02e --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt @@ -0,0 +1,169 @@ +package com.lagradost.cloudstream4.download + +import android.content.Context +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream4.android.DesktopAndroid +import java.io.File + +/** A title with downloads: a movie, or a series with its downloaded episodes */ +data class DownloadedTitle( + val id: Int, + val apiName: String, + val url: String, + val name: String, + val type: TvType, + val poster: String?, +) + +/** One downloaded or downloading file: an episode, or a movie with [episode] null */ +data class DownloadedEpisode( + val id: Int, + val parentId: Int, + val name: String?, + val episode: Int?, + val season: Int?, + val poster: String?, + val description: String?, +) + +/** Where a download is saved and how big it will be */ +data class DownloadFile( + /** The full size once done, an estimate while an HLS stream downloads */ + val totalBytes: Long, + /** The downloads folder picked in the settings when it started */ + val root: File, + /** For example "TVSeries/<title>", with forward slashes as on Android */ + val relativePath: String, + val displayName: String, + /** The HLS segments written so far, to carry on from there */ + val segmentsDone: Int? = null, + /** The file's length when [segmentsDone] was saved: a segment written after it is cut off on resume */ + val segmentBytes: Long? = null, + /** The url hash of the link it came from: carrying on with another link would mix two files */ + val linkHash: Int? = null, +) { + val folder: File get() = if (relativePath.isBlank()) root else root.resolve(relativePath) + val file: File get() = folder.resolve(displayName) +} + +/** What finds the links of a download again, to resume it after the app restarts */ +data class DownloadSource(val apiName: String, val data: String) + +/** + * Downloads in the Android app's keys and JSON, so its download list and the desktop's read the same + * data: the title in "download_header_cache", the episode in "download_episode_cache/<title id>" and + * the file in "download_info". Desktop adds "download_desktop_source" to find the links again. + */ +class DownloadStore(private val context: Context = DesktopAndroid.application) { + /** The Android app's DownloadHeaderCached, also written by the watch store for Continue watching */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class HeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + @JsonProperty("id") val id: Int, + ) + + /** The Android app's DownloadEpisodeCached. A movie is saved as episode 0 under its own id */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class EpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + @JsonProperty("id") val id: Int, + ) + + /** The Android app's DownloadedFileInfo, with an absolute folder as its base path */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class FileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null, + @JsonProperty("linkHash") val linkHash: Int? = null, + /** Desktop only, Android ignores it */ + @JsonProperty("segmentBytes") val segmentBytes: Long? = null, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class Source( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("data") val data: String, + ) + + fun saveTitle(title: DownloadedTitle) { + context.setKey("$HEADER/${title.id}", HeaderCached(title.apiName, title.url, title.type, title.name, title.poster, System.currentTimeMillis(), title.id)) + } + + fun title(id: Int): DownloadedTitle? = context.getKey("$HEADER/$id", HeaderCached::class.java) + ?.let { DownloadedTitle(it.id, it.apiName, it.url, it.name, it.type, it.poster) } + + fun saveEpisode(episode: DownloadedEpisode, source: DownloadSource) { + context.setKey( + "$EPISODE/${episode.parentId}/${episode.id}", + EpisodeCached(episode.name, episode.poster, episode.episode ?: 0, episode.season, episode.parentId, episode.description, System.currentTimeMillis(), episode.id), + ) + context.setKey("$SOURCE/${episode.id}", Source(source.apiName, source.data)) + } + + /** The episodes saved under a title, whether or not their file is there */ + fun episodes(parentId: Int): List<DownloadedEpisode> { + val movie = title(parentId)?.type?.isEpisodeBased() == false + return context.getKeys("$EPISODE/$parentId").mapNotNull { key -> + val cached = context.getKey(key, EpisodeCached::class.java) ?: return@mapNotNull null + // A movie is the title's own id, saved as episode 0 + val isMovie = movie && cached.id == parentId + DownloadedEpisode(cached.id, cached.parentId, cached.name, cached.episode.takeUnless { isMovie }, cached.season, cached.poster, cached.description) + } + } + + fun episode(parentId: Int, id: Int): DownloadedEpisode? = episodes(parentId).firstOrNull { it.id == id } + + /** The ids of every title with episodes saved */ + fun titleIds(): Set<Int> = context.getKeys(EPISODE).mapNotNull { it.split('/').getOrNull(1)?.toIntOrNull() }.toSet() + + fun source(id: Int): DownloadSource? = context.getKey("$SOURCE/$id", Source::class.java)?.let { DownloadSource(it.apiName, it.data) } + + fun saveFile(id: Int, file: DownloadFile) { + context.setKey( + "$INFO/$id", + FileInfo(file.totalBytes, file.relativePath, file.displayName, file.segmentsDone?.toString(), file.root.absolutePath, file.linkHash, file.segmentBytes), + ) + } + + fun file(id: Int): DownloadFile? { + val info = context.getKey("$INFO/$id", FileInfo::class.java) ?: return null + // Android keeps a content:// uri or nothing for its own Downloads folder, which desktop cannot use + val root = info.basePath?.takeUnless { it.isBlank() || it.startsWith("content:") }?.let(::File) ?: return null + return DownloadFile(info.totalBytes, root, info.relativePath, info.displayName, info.extraInfo?.toIntOrNull(), info.segmentBytes, info.linkHash) + } + + /** Forgets the file and the episode. The title stays, Continue watching may use it */ + fun remove(parentId: Int, id: Int) { + context.removeKey("$INFO/$id") + context.removeKey("$SOURCE/$id") + context.removeKey("$EPISODE/$parentId/$id") + } + + companion object { + private const val HEADER = "download_header_cache" + private const val EPISODE = "download_episode_cache" + private const val INFO = "download_info" + private const val SOURCE = "download_desktop_source" + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloader.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloader.kt new file mode 100644 index 00000000000..8efb7fcc693 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloader.kt @@ -0,0 +1,145 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.M3u8Helper2 +import com.lagradost.cloudstream4.network.DesktopHttp +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** How far a file is: bytes written, and the full size when known */ +fun interface DownloadProgress { + /** [segmentsDone] is set for HLS, after each segment is on disk */ + fun update(bytes: Long, total: Long, segmentsDone: Int?) +} + +/** + * Writes one link to a file: a plain video, carrying on where an earlier attempt stopped when the + * server allows it, or an HLS stream, its segments joined into one file as the Android app does. + */ +object Downloader { + /** Without the HTTP cache, which must not hold whole videos */ + private val http: OkHttpClient by lazy { + DesktopHttp.client.newBuilder().cache(null).readTimeout(30, TimeUnit.SECONDS).build() + } + + fun canDownload(link: ExtractorLink) = link.type == ExtractorLinkType.VIDEO || link.type == ExtractorLinkType.M3U8 + + /** The link's headers with a user agent and its referer, the link's own winning */ + fun headers(link: ExtractorLink): Map<String, String> { + val headers = link.headers.toMutableMap() + if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) headers["User-Agent"] = USER_AGENT + if (link.referer.isNotBlank() && headers.keys.none { it.equals("Referer", ignoreCase = true) }) headers["Referer"] = link.referer + return headers + } + + /** + * Downloads [link] to [file], carrying on from [resume] when an earlier attempt with the same link + * got that far. Throws when the link cannot be downloaded. + */ + suspend fun download(link: ExtractorLink, file: File, resume: Resume?, connections: Int, progress: DownloadProgress) { + file.parentFile?.mkdirs() + if (link.type == ExtractorLinkType.M3U8) hls(link, file, resume, connections, progress) + else video(link, file, if (resume != null) file.length() else 0, progress) + } + + /** How far an earlier attempt got: for HLS, the segments done and the file's length after them */ + data class Resume(val segments: Int, val bytes: Long) + + private suspend fun video(link: ExtractorLink, file: File, resumeFrom: Long, progress: DownloadProgress) = withContext(Dispatchers.IO) { + val start = if (resumeFrom > 0 && file.length() >= resumeFrom) resumeFrom else 0 + val request = Request.Builder().url(link.url.replace(" ", "%20")).apply { + headers(link).forEach { (key, value) -> header(key, value) } + if (start > 0) header("Range", "bytes=$start-") + }.build() + val call = http.newCall(request) + val cancel = currentCoroutineContext()[Job]?.invokeOnCompletion { call.cancel() } + try { + call.execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code}") + val type = response.header("Content-Type").orEmpty().lowercase() + // A web page where the video should be: an expired or protected link + if (type.startsWith("text/") || type.contains("json")) throw IOException("The link gave a web page, not a video") + // 206 carries on, a 200 sends the whole file again + val from = if (response.code == 206) start else 0 + val length = response.body.contentLength() + val total = if (length > 0) from + length else -1 + FileOutputStream(file, from > 0).use { out -> + var written = from + var reported = 0L + val buffer = ByteArray(256 * 1024) + response.body.byteStream().use { input -> + while (true) { + ensureActive() + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + written += read + val now = System.nanoTime() + if (now - reported > 500_000_000) { + reported = now + progress.update(written, total, null) + } + } + } + progress.update(written, if (total > 0) total else written, null) + if (total > 0 && written < total) throw IOException("The connection closed early") + } + } + } finally { + cancel?.dispose() + } + } + + private suspend fun hls(link: ExtractorLink, file: File, resume: Resume?, connections: Int, progress: DownloadProgress) { + val stream = M3u8Helper2.hslLazy(M3u8Helper.M3u8Stream(link.url, link.quality, headers(link)), selectBest = true, requireAudio = true) + val size = stream.size + if (size == 0) throw IOException("The stream has no segments") + // Only when the file still holds all the segments saved, without what came after them + val first = resume?.takeIf { file.length() >= it.bytes }?.segments?.coerceIn(0, size) ?: 0 + withContext(Dispatchers.IO) { + if (first > 0) java.io.RandomAccessFile(file, "rw").use { it.setLength(resume!!.bytes) } + FileOutputStream(file, first > 0).use { out -> + var written = file.length() + // A few segments load ahead while one is written, in order + val ahead = connections.coerceIn(1, 8) + coroutineScope { + val pending = ArrayDeque<Deferred<ByteArray?>>() + var next = first + fun fill() { + while (pending.size < ahead && next < size) { + val index = next++ + pending.addLast(async { stream.resolveLinkSafe(index) }) + } + } + fill() + var index = first + while (pending.isNotEmpty()) { + val bytes = pending.removeFirst().await() ?: throw IOException("Segment ${index + 1} of $size could not be loaded") + out.write(bytes) + out.flush() + written += bytes.size + index++ + // The size is known once all segments are in, until then it is guessed from those so far + progress.update(written, if (index == size) written else written / index * size, index) + fill() + } + } + } + } + } +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt new file mode 100644 index 00000000000..cf52e25bc84 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt @@ -0,0 +1,315 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream4.network.DesktopHttp +import com.lagradost.cloudstream4.player.DataStoreSubtitleSettings +import com.lagradost.cloudstream4.player.LinksViewModel +import com.lagradost.cloudstream4.player.SubtitleLanguages +import com.lagradost.cloudstream4.providers.loadLinksSafely +import com.lagradost.cloudstream4.settings +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.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.withContext +import okhttp3.Request +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +enum class DownloadStatus { Queued, FindingLinks, Downloading, Paused, Failed, Done } + +data class DownloadState( + val status: DownloadStatus, + val bytes: Long = 0, + /** The full size, 0 while unknown */ + val total: Long = 0, + /** Why it failed */ + val message: String? = null, +) { + val fraction: Float? get() = if (total > 0) (bytes.toFloat() / total).coerceIn(0f, 1f) else null + val active: Boolean get() = status == DownloadStatus.Queued || status == DownloadStatus.FindingLinks || status == DownloadStatus.Downloading +} + +/** What to download: an episode, or a movie, and what finds its links */ +data class DownloadRequest(val title: DownloadedTitle, val episode: DownloadedEpisode, val source: DownloadSource) + +/** Finds the links and subtitles of a download, as the player does */ +fun interface LinkFinder { + suspend fun find(source: DownloadSource, onLink: (ExtractorLink) -> Unit, onSubtitle: (SubtitleFile) -> Unit) +} + +/** + * Downloads episodes and movies to play offline, a few at a time. Each one finds its links, then tries + * them best quality first until one downloads; a download that stopped carries on where it was when + * it is resumed with the same link. Subtitles in the language picked for auto-select are saved + * beside the video, as on Android. + */ +class Downloads( + private val store: DownloadStore, + private val scope: CoroutineScope, + private val finder: LinkFinder, + /** The downloads folder from the settings */ + private val root: () -> File, + private val parallel: Int = 3, + private val connections: () -> Int = { 3 }, + private val subtitleLanguage: () -> String? = { null }, +) { + private val _states = MutableStateFlow<Map<Int, DownloadState>>(emptyMap()) + + /** Every download's state, by its episode id (a movie's is the title's id) */ + val states: StateFlow<Map<Int, DownloadState>> = _states.asStateFlow() + + private val jobs = ConcurrentHashMap<Int, Job>() + private val slots = Semaphore(parallel.coerceIn(1, 10)) + + init { + // Downloads from before: done, or stopped halfway and ready to carry on + val found = HashMap<Int, DownloadState>() + for (parentId in store.titleIds()) for (episode in store.episodes(parentId)) { + val file = store.file(episode.id) ?: continue + val length = file.file.length() + if (!file.file.isFile) continue + found[episode.id] = if (isComplete(length, file.totalBytes)) DownloadState(DownloadStatus.Done, length, length) + else DownloadState(DownloadStatus.Paused, length, file.totalBytes) + } + _states.value = found + } + + private fun set(id: Int, state: DownloadState?) = _states.update { if (state == null) it - id else it + (id to state) } + + /** Changes a download's state, unless it was deleted meanwhile */ + private fun change(id: Int, change: DownloadState.() -> DownloadState) = + _states.update { states -> states[id]?.let { states + (id to it.change()) } ?: states } + + fun state(id: Int): DownloadState? = states.value[id] + + /** Where new downloads go */ + val folder: File get() = root() + + /** Titles with a download, done or not, by name */ + fun titles(): List<DownloadedTitle> { + val ids = states.value.keys + return store.titleIds().mapNotNull { parentId -> + store.title(parentId)?.takeIf { store.episodes(parentId).any { it.id in ids } } + }.sortedBy { it.name.lowercase() } + } + + /** A title's downloads, in season and episode order */ + fun episodes(parentId: Int): List<DownloadedEpisode> { + val ids = states.value.keys + return store.episodes(parentId).filter { it.id in ids }.sortedWith(compareBy({ it.season ?: 0 }, { it.episode ?: 0 })) + } + + fun title(parentId: Int): DownloadedTitle? = store.title(parentId) + + /** The finished file of a download */ + fun file(id: Int): File? = store.file(id)?.file?.takeIf { state(id)?.status == DownloadStatus.Done && it.isFile } + + /** Subtitles saved beside a download: their language, as in the file name, and the file */ + fun subtitles(id: Int): List<Pair<String, File>> { + val video = file(id) ?: return emptyList() + val base = video.nameWithoutExtension + return subtitleFiles(video).map { it.nameWithoutExtension.removePrefix(base).trim().ifEmpty { "Subtitles" } to it }.sortedBy { it.first } + } + + /** "<video name> English.vtt" beside the video, but not "Episode 10 English.vtt" beside "Episode 1" */ + private fun subtitleFiles(video: File): List<File> { + val base = video.nameWithoutExtension + return video.parentFile?.listFiles().orEmpty().filter { + it != video && it.isFile && it.extension.lowercase() in SUBTITLE_EXTENSIONS && + (it.nameWithoutExtension == base || it.nameWithoutExtension.startsWith("$base ")) + } + } + + fun start(request: DownloadRequest) { + val id = request.episode.id + if (jobs[id]?.isActive == true || state(id)?.status == DownloadStatus.Done) return + store.saveTitle(request.title) + store.saveEpisode(request.episode, request.source) + val before = state(id) + set(id, DownloadState(DownloadStatus.Queued, before?.bytes ?: 0, before?.total ?: 0)) + jobs[id] = scope.launch { + try { + slots.withPermit { run(request) } + } catch (e: CancellationException) { + // Paused, or deleted, which has taken the state away already + change(id) { copy(status = DownloadStatus.Paused) } + throw e + } finally { + jobs.remove(id, coroutineContext[Job]) + } + } + } + + /** Carries on with a paused or failed download, finding its links again */ + fun resume(parentId: Int, id: Int) { + val title = store.title(parentId) ?: return + val episode = store.episode(parentId, id) ?: return + val source = store.source(id) ?: return + start(DownloadRequest(title, episode, source)) + } + + fun canResume(parentId: Int, id: Int) = store.source(id) != null && store.title(parentId) != null + + fun pause(id: Int) { + jobs[id]?.cancel() + } + + /** Stops the download and deletes its file and subtitles */ + fun delete(parentId: Int, id: Int) { + val job = jobs.remove(id) + val files = listOfNotNull(store.file(id)?.file) + subtitlesOf(id) + set(id, null) + store.remove(parentId, id) + scope.launch { + // The file is open until the download stops, and Windows cannot delete an open file + job?.cancelAndJoin() + withContext(Dispatchers.IO) { files.forEach { it.delete() } } + } + } + + private fun subtitlesOf(id: Int): List<File> = store.file(id)?.file?.let(::subtitleFiles).orEmpty() + + private suspend fun run(request: DownloadRequest) { + val id = request.episode.id + change(id) { copy(status = DownloadStatus.FindingLinks, message = null) } + val links = ArrayList<ExtractorLink>() + val subtitles = ArrayList<SubtitleFile>() + try { + finder.find( + request.source, + onLink = { link -> + synchronized(links) { + if (link !is DrmExtractorLink && Downloader.canDownload(link) && links.none { it.url == link.url }) links += link + } + }, + onSubtitle = { sub -> synchronized(subtitles) { if (subtitles.none { it.url == sub.url }) subtitles += sub } }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + change(id) { copy(status = DownloadStatus.Failed, message = e.message ?: e.javaClass.simpleName) } + return + } + val previous = store.file(id) + // The link it started with first, so it can carry on, then best quality first + val ordered = synchronized(links) { links.toList() } + .sortedWith(compareBy<ExtractorLink> { it.url.hashCode() != previous?.linkHash }.thenByDescending { LinksViewModel.rank(it.quality) }) + if (ordered.isEmpty()) { + change(id) { copy(status = DownloadStatus.Failed, message = "No downloadable links found") } + return + } + + val title = request.title + val episode = request.episode + val name = DownloadNames.fileName(title.name, episode.episode, episode.season, episode.name) + var lastError: String? = null + for (link in ordered) { + val same = previous != null && previous.linkHash == link.url.hashCode() && previous.file.isFile + var file = DownloadFile( + totalBytes = if (same) previous!!.totalBytes else 0, + root = previous?.root ?: root(), + relativePath = previous?.relativePath ?: DownloadNames.folder(title.type, title.name), + displayName = previous?.displayName ?: "$name.mp4", + segmentsDone = if (same) previous!!.segmentsDone else null, + segmentBytes = if (same) previous!!.segmentBytes else null, + linkHash = link.url.hashCode(), + ) + store.saveFile(id, file) + val resume = when { + !same -> null + link.type == ExtractorLinkType.M3U8 -> previous!!.segmentsDone?.let { Downloader.Resume(it, previous.segmentBytes ?: 0) } + else -> Downloader.Resume(0, 0) + } + change(id) { DownloadState(DownloadStatus.Downloading, if (same) file.file.length() else 0, file.totalBytes) } + var saved = 0L + try { + Downloader.download(link, file.file, resume, connections()) { bytes, total, segments -> + file = file.copy(totalBytes = maxOf(total, bytes), segmentsDone = segments ?: file.segmentsDone, segmentBytes = if (segments != null) bytes else file.segmentBytes) + change(id) { DownloadState(DownloadStatus.Downloading, bytes, file.totalBytes) } + // The store writes a whole file, so not on every update + val now = System.nanoTime() + if (now - saved > SAVE_EVERY_NS) { + saved = now + store.saveFile(id, file) + } + } + val length = file.file.length() + store.saveFile(id, file.copy(totalBytes = length)) + saveSubtitles(subtitles, file.copy(totalBytes = length)) + change(id) { DownloadState(DownloadStatus.Done, length, length) } + return + } catch (e: CancellationException) { + store.saveFile(id, file) + throw e + } catch (e: Throwable) { + store.saveFile(id, file) + lastError = e.message ?: e.javaClass.simpleName + println("WARN Downloads: ${link.name} failed for ${title.name}: $lastError") + } + } + change(id) { copy(status = DownloadStatus.Failed, message = lastError) } + } + + /** Up to three subtitles in the language picked for auto-select, named after the video as on Android */ + private suspend fun saveSubtitles(subtitles: List<SubtitleFile>, file: DownloadFile) = withContext(Dispatchers.IO) { + val tag = subtitleLanguage() ?: return@withContext + val chosen = subtitles.filter { SubtitleLanguages.tagOf(it.lang) == tag }.take(3) + val base = file.file.nameWithoutExtension + chosen.forEachIndexed { index, sub -> + runCatching { + val extension = if (sub.url.contains(".srt")) "srt" else "vtt" + val label = DownloadNames.sanitize(sub.lang).ifEmpty { "Subtitles" } + if (chosen.size > 1) " ${index + 1}" else "" + val request = Request.Builder().url(sub.url).apply { + header("User-Agent", USER_AGENT) + sub.headers?.forEach { (key, value) -> header(key, value) } + }.build() + DesktopHttp.client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("HTTP ${response.code}") + file.folder.resolve("$base $label.$extension").writeBytes(response.body.bytes()) + } + }.onFailure { println("WARN Downloads: subtitle ${sub.lang} could not be saved: ${it.message}") } + } + } + + companion object { + private val SUBTITLE_EXTENSIONS = setOf("vtt", "srt", "txt", "ass", "ttml", "sbv", "dfxp") + private const val SAVE_EVERY_NS = 2_000_000_000L + + /** Done when within 1 KB of the full size, as on Android */ + fun isComplete(bytes: Long, total: Long) = total > 0 && bytes > 1024 && bytes + 1024 >= total + + /** The folder picked in the settings, or Downloads\CloudStream in the user's folder */ + fun defaultRoot(): File = File(System.getProperty("user.home"), "Downloads").resolve("CloudStream") + + val instance: Downloads by lazy { + Downloads( + store = DownloadStore(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + finder = { source, onLink, onSubtitle -> + val api = APIHolder.getApiFromNameNull(source.apiName) + ?: throw IllegalStateException("${source.apiName} is not loaded") + api.loadLinksSafely(source.data, onSubtitle = onSubtitle, onLink = onLink) + }, + root = { settings.general.downloadPath.get().takeIf { it.isNotBlank() }?.let(::File) ?: defaultRoot() }, + parallel = settings.general.parallelDownloads.get(), + connections = { settings.general.concurrentConnections.get() }, + subtitleLanguage = { DataStoreSubtitleSettings.instance.autoSelect.value }, + ) + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt new file mode 100644 index 00000000000..4b6bd1862ba --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt @@ -0,0 +1,163 @@ +package com.lagradost.cloudstream4.download + +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream4.android.DesktopAndroid +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.File +import java.net.InetSocketAddress +import java.nio.file.Files +import java.util.Collections +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DownloadsTest { + private val dir = Files.createTempDirectory("cs-downloads").toFile() + + init { + DesktopAndroid.init(dir.toPath().resolve("home")) + } + + private val video = ByteArray(300_000) { (it % 251).toByte() } + private val segments = List(3) { n -> ByteArray(50_000) { (n * 7 + it % 13).toByte() } } + private val ranges = Collections.synchronizedList(ArrayList<String?>()) + private val segmentHits = Collections.synchronizedList(ArrayList<Int>()) + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/video.mp4") { exchange -> + val range = exchange.requestHeaders.getFirst("Range") + ranges += range + val from = range?.removePrefix("bytes=")?.substringBefore('-')?.toInt() ?: 0 + exchange.responseHeaders.add("Content-Type", "video/mp4") + if (from > 0) exchange.responseHeaders.add("Content-Range", "bytes $from-${video.size - 1}/${video.size}") + exchange.send(if (from > 0) 206 else 200, video.copyOfRange(from, video.size)) + } + createContext("/page.mp4") { it.responseHeaders.add("Content-Type", "text/html"); it.send(200, "<html>".toByteArray()) } + createContext("/stream.m3u8") { exchange -> + val playlist = buildString { + appendLine("#EXTM3U") + appendLine("#EXT-X-TARGETDURATION:10") + segments.indices.forEach { appendLine("#EXTINF:10,"); appendLine("seg$it.ts") } + appendLine("#EXT-X-ENDLIST") + } + exchange.send(200, playlist.toByteArray()) + } + segments.indices.forEach { n -> + createContext("/seg$n.ts") { segmentHits += n; it.send(200, segments[n]) } + } + createContext("/sub.vtt") { it.send(200, "WEBVTT\n\n00:00.000 --> 00:01.000\nHi\n".toByteArray()) } + start() + } + private val base = "http://127.0.0.1:${server.address.port}" + + private fun HttpExchange.send(code: Int, body: ByteArray) { + sendResponseHeaders(code, body.size.toLong()) + responseBody.use { it.write(body) } + } + + @AfterTest + fun cleanup() { + server.stop(0) + dir.deleteRecursively() + } + + private fun link(path: String, type: ExtractorLinkType = ExtractorLinkType.VIDEO) = runBlocking { + newExtractorLink("Test", "Test", "$base$path", type) { quality = 1080 } + } + + @Test + fun namesFilesAndFoldersAsAndroidDoes() { + assertEquals("TVSeries/A show 2", DownloadNames.folder(TvType.TvSeries, "A show: 2?")) + assertEquals("Movies", DownloadNames.folder(TvType.Movie, "A film")) + assertEquals("Season 1 Episode 2 - Pilot", DownloadNames.fileName("A show", 2, 1, "Pilot")) + assertEquals("Episode 2", DownloadNames.fileName("A show", 2, null, " ")) + assertEquals("A film", DownloadNames.fileName("A film.", null, null, null)) + } + + @Test + fun aVideoCarriesOnWhereItStopped() = runBlocking { + val file = File(dir, "out/video.mp4") + file.parentFile.mkdirs() + file.writeBytes(video.copyOf(100_000)) + Downloader.download(link("/video.mp4"), file, Downloader.Resume(0, 0), 3) { _, _, _ -> } + assertEquals("bytes=100000-", ranges.single()) + assertContentEquals(video, file.readBytes()) + } + + @Test + fun aWebPageIsNotSavedAsAVideo() { + val file = File(dir, "page.mp4") + val error = runCatching { runBlocking { Downloader.download(link("/page.mp4"), file, null, 3) { _, _, _ -> } } }.exceptionOrNull() + assertNotNull(error) + } + + @Test + fun anHlsStreamIsJoinedAndResumesAfterTheLastSavedSegment() = runBlocking { + val file = File(dir, "hls.mp4") + var last: Triple<Long, Long, Int?>? = null + Downloader.download(link("/stream.m3u8", ExtractorLinkType.M3U8), file, null, 2) { bytes, total, done -> last = Triple(bytes, total, done) } + val joined = segments.reduce { a, b -> a + b } + assertContentEquals(joined, file.readBytes()) + assertEquals(Triple(joined.size.toLong(), joined.size.toLong(), 3), last) + + // Stopped after segment 1 was saved, with part of segment 2 written after that + file.writeBytes(segments[0] + segments[1].copyOf(10_000)) + segmentHits.clear() + Downloader.download(link("/stream.m3u8", ExtractorLinkType.M3U8), file, Downloader.Resume(1, segments[0].size.toLong()), 2) { _, _, _ -> } + assertContentEquals(joined, file.readBytes()) + assertEquals(listOf(1, 2), segmentHits.sorted()) + } + + @Test + fun downloadsFromTheFoundLinksAndKeepsTheAndroidKeys() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val root = File(dir, "Downloads") + val finder = LinkFinder { _, onLink, onSubtitle -> + onLink(link("/page.mp4").apply { quality = 2160 }) + onLink(link("/video.mp4")) + onLink(link("/stream.m3u8", ExtractorLinkType.MAGNET)) + onSubtitle(SubtitleFile("English", "$base/sub.vtt")) + } + val downloads = Downloads(DownloadStore(), scope, finder, root = { root }, subtitleLanguage = { "en" }) + val title = DownloadedTitle(7, "Test", "$base/show", "A show", TvType.TvSeries, null) + val episode = DownloadedEpisode(70, 7, "Pilot", 1, 1, null, null) + downloads.start(DownloadRequest(title, episode, DownloadSource("Test", "data"))) + val done = withTimeout(20_000) { downloads.states.first { it[70]?.status == DownloadStatus.Done || it[70]?.status == DownloadStatus.Failed } } + assertEquals(DownloadStatus.Done, done[70]?.status, done[70]?.message) + + val file = downloads.file(70)!! + assertEquals(File(root, "TVSeries/A show/Season 1 Episode 1 - Pilot.mp4"), file) + assertContentEquals(video, file.readBytes()) + assertEquals(listOf("English"), downloads.subtitles(70).map { it.first }) + assertEquals(listOf(title), downloads.titles()) + assertEquals(listOf(70), downloads.episodes(7).map { it.id }) + + // A new start reads what is saved: the finished file is still done + val again = Downloads(DownloadStore(), scope, finder, root = { root }) + assertEquals(DownloadStatus.Done, again.state(70)?.status) + assertTrue(again.canResume(7, 70)) + + again.delete(7, 70) + withTimeout(5_000) { while (file.exists()) kotlinx.coroutines.delay(50) } + assertFalse(File(root, "TVSeries/A show/Season 1 Episode 1 - Pilot English.vtt").exists()) + assertNull(again.state(70)) + assertTrue(again.titles().isEmpty()) + scope.cancel() + } +} From 2496992c4f85aa9d30e00841de87a5b686a61d7e Mon Sep 17 00:00:00 2001 From: harsh-pockt <harshsaini@pocketful.trade> Date: Sat, 3 Oct 2026 14:35:12 +0530 Subject: [PATCH 21/64] Desktop: solve Cloudflare checks and WebView lookups with Edge Extensions' WebViewResolver and CloudflareKiller now use Microsoft Edge (or Chrome) through the DevTools protocol, with its own profile and its window off screen. A page waiting on Cloudflare's check comes on screen with a toast, so the user can tick the box. CloudflareKiller retries with the browser's cf_clearance cookie and user agent, and falls back to loading the request in the browser when the site still refuses other clients. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../cloudstream3/network/CloudflareKiller.kt | 92 +++- .../kotlin/com/lagradost/cloudstream4/Main.kt | 4 + .../cloudstream4/WindowsFullscreen.kt | 8 + .../settings/ExtensionsSettings.kt | 2 +- .../cloudstream3/network/DesktopWebView.kt | 42 ++ .../network/WebViewResolver.jvm.kt | 65 ++- shared/build.gradle.kts | 4 + .../cloudstream4/browser/SystemBrowser.kt | 507 ++++++++++++++++++ .../cloudstream4/browser/SystemBrowserTest.kt | 90 ++++ 9 files changed, 796 insertions(+), 18 deletions(-) create mode 100644 library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/DesktopWebView.kt create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt index b01f65daf8a..4408d1b49fa 100644 --- a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -1,18 +1,27 @@ package com.lagradost.cloudstream3.network +import com.lagradost.cloudstream3.app +import com.lagradost.nicehttp.Requests.Companion.await +import com.lagradost.nicehttp.cookies +import kotlinx.coroutines.runBlocking import okhttp3.Headers import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response import java.net.URI /** - * The Android app's CloudflareKiller. On Android it solves Cloudflare's check in a WebView, which - * desktop does not have, so here it only adds cookies saved for the host and otherwise passes - * requests through unchanged. Sites behind the check fail as they would without it. + * The Android app's CloudflareKiller. A response from Cloudflare refusing the request (403 or 503) + * opens the page in the desktop's browser ([DesktopWebView]) until Cloudflare's check passes and + * gives its cf_clearance cookie; the request is then sent again with the browser's cookies and user + * agent. A site that still refuses it, because it only trusts the browser itself, gets the request + * from inside the browser. Without a browser, requests go through unchanged. */ class CloudflareKiller : Interceptor { companion object { const val TAG = "CloudflareKiller" + private val ERROR_CODES = listOf(403, 503) + private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare") fun parseCookieMap(cookie: String): Map<String, String> { return cookie.split(";").associate { @@ -24,7 +33,7 @@ class CloudflareKiller : Interceptor { val savedCookies: MutableMap<String, Map<String, String>> = mutableMapOf() - /** Gets the headers with the saved cookies */ + /** Gets the headers with the saved cookies, with the browser's user agent */ fun getCookieHeaders(url: String): Headers { val userAgentHeaders = WebViewResolver.webViewUserAgent?.let { mapOf("user-agent" to it) @@ -33,10 +42,77 @@ class CloudflareKiller : Interceptor { return getHeaders(userAgentHeaders, savedCookies[URI(url).host] ?: emptyMap()) } - override fun intercept(chain: Interceptor.Chain): Response { + override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() - val cookies = savedCookies[request.url.host] ?: return chain.proceed(request) - val cookieHeader = cookies.entries.joinToString(" ") { "${it.key}=${it.value};" } - return chain.proceed(request.newBuilder().header("Cookie", cookieHeader).build()) + + when (val cookies = savedCookies[request.url.host]) { + null -> { + val response = chain.proceed(request) + if (!isRefused(response)) { + return@runBlocking response + } else { + response.close() + bypassCloudflare(request)?.let { + println("INFO $TAG: Succeeded bypassing cloudflare: ${request.url}") + return@runBlocking it + } + } + } + + else -> return@runBlocking proceed(request, cookies) + } + + println("WARN $TAG: Failed cloudflare at: ${request.url}") + return@runBlocking chain.proceed(request) + } + + /** + * Returns true if the cf cookies were found in the browser. Also saves the cookies. + */ + private fun trySolveWithSavedCookies(request: Request): Boolean { + val cookie = runCatching { DesktopWebView.engine?.cookies(request.url.toString()) }.getOrNull() ?: return false + return cookie.contains("cf_clearance").also { solved -> + if (solved) savedCookies[request.url.host] = parseCookieMap(cookie) + } + } + + private suspend fun proceed(request: Request, cookies: Map<String, String>): Response { + val userAgentMap = WebViewResolver.webViewUserAgent?.let { + mapOf("user-agent" to it) + } ?: emptyMap() + + val headers = getHeaders(request.headers.toMap() + userAgentMap, cookies + request.cookies) + val response = app.baseClient.newCall( + request.newBuilder() + .headers(headers) + .build() + ).await() + if (!isRefused(response)) return response + val fetched = runCatching { DesktopWebView.engine?.fetch(request) }.getOrNull() ?: return response + response.close() + return fetched + } + + private fun isRefused(response: Response) = response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES + + private suspend fun bypassCloudflare(request: Request): Response? { + if (DesktopWebView.engine == null) return null + if (!trySolveWithSavedCookies(request)) { + println("INFO $TAG: Loading the browser to solve cloudflare for ${request.url}") + WebViewResolver( + // Never exit based on url + Regex(".^"), + // Cloudflare needs the browser's own user agent + userAgent = null, + useOkhttp = false, + // Match every url for the requestCallBack + additionalUrls = listOf(Regex(".")) + ).resolveUsingWebView(request.url.toString()) { + trySolveWithSavedCookies(request) + } + } + + val cookies = savedCookies[request.url.host] ?: return null + return proceed(request, cookies) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 4eb6d2d06e0..bb99d0fdb5e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -35,6 +35,7 @@ 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.android.DesktopAndroid +import com.lagradost.cloudstream4.browser.SystemBrowser import com.lagradost.cloudstream4.network.DesktopHttp import com.lagradost.cloudstream4.plugins.DesktopPluginManager import com.lagradost.cloudstream4.settings.AppUpdatePrompt @@ -62,6 +63,9 @@ fun main() { // Before the extensions load: they make their requests through it @OptIn(DelicateCoroutinesApi::class) DesktopHttp.start(GlobalScope) + // Edge or Chrome stands in for Android's WebView, for extensions behind Cloudflare and the like + // The app is in front when an extension needs the browser, and lets it come forward + SystemBrowser.install()?.onShow = WindowsForeground::allow // Plugins load in the background so a slow or broken plugin never delays the window @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt index 5a4668504c1..d32a557ae2f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt @@ -18,6 +18,7 @@ private interface User32 : StdCallLibrary { fun ShowWindow(hwnd: Pointer, cmd: Int): Boolean fun IsZoomed(hwnd: Pointer): Boolean fun SetForegroundWindow(hwnd: Pointer): Boolean + fun AllowSetForegroundWindow(processId: Int): Boolean } private val user32: User32? by lazy { @@ -131,3 +132,10 @@ object WindowsHide { user32.SetForegroundWindow(hwnd) } } + +/** Lets another program's window come in front of the app's, which Windows otherwise refuses it */ +object WindowsForeground { + fun allow(processId: Long) { + user32?.AllowSetForegroundWindow(processId.toInt()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt index d040a5394cc..7f7910e906e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -523,7 +523,7 @@ class RepositoryScreen(private val url: String) : SearchableSettings { Preference.PreferenceItem.InfoPreference( "These extensions were built for Android. Desktop converts them when you install " + "them, which takes a few seconds. Searching and playing work for most; their " + - "settings screens and anything that needs Android's WebView do not." + "settings screens do not. Sites behind Cloudflare open in Microsoft Edge in the background." ) ) + android.map { row(it) }, ), diff --git a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/DesktopWebView.kt b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/DesktopWebView.kt new file mode 100644 index 00000000000..f11f1ff4fd4 --- /dev/null +++ b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/DesktopWebView.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.network + +import okhttp3.Request +import okhttp3.Response + +/** + * The browser behind WebViewResolver on desktop, which has no Android WebView. The desktop app + * installs one in [DesktopWebView.engine]; without one, WebViewResolver finds nothing and requests + * go through unchanged. + */ +interface DesktopWebView { + /** The browser's own user agent, which cookies it earned only work with */ + val userAgent: String? + + /** + * Loads [request] in a new page and reports each request the page makes to [onRequest], until + * [onRequest] returns true or [timeoutMs] passes. [script] runs after each request, its result + * going to [onScript]. [userAgent] replaces the browser's own when set. + */ + suspend fun load( + request: Request, + userAgent: String?, + script: String?, + onScript: ((String) -> Unit)?, + timeoutMs: Long, + onRequest: (Request) -> Boolean, + ) + + /** The browser's cookies for [url] as a Cookie header, like Android's CookieManager.getCookie */ + fun cookies(url: String): String? + + /** + * Makes [request] from inside the browser, on a page of the same site, for sites that refuse + * any other client even with the browser's cookies. Null when the browser could not make it + */ + suspend fun fetch(request: Request): Response? + + companion object { + @Volatile + var engine: DesktopWebView? = null + } +} diff --git a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt index 39e0d51f95e..808b0574951 100644 --- a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt +++ b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.jvm.kt @@ -1,11 +1,17 @@ package com.lagradost.cloudstream3.network +import com.lagradost.api.Log import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.nicehttp.requestCreator import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response +import java.util.Collections +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.runBlocking + +private const val TAG = "WebViewResolver" /** * When used as Interceptor additionalUrls cannot be returned, use WebViewResolver(...).resolveUsingWebView(...) @@ -18,24 +24,33 @@ import okhttp3.Response * @param timeout close webview after timeout * */ actual class WebViewResolver actual constructor( - interceptUrl: Regex, - additionalUrls: List<Regex>, - userAgent: String?, - useOkhttp: Boolean, - script: String?, - scriptCallback: ((String) -> Unit)?, - timeout: Long + val interceptUrl: Regex, + val additionalUrls: List<Regex>, + val userAgent: String?, + // The browser makes every request itself: desktop has no way to hand them to okhttp + @Suppress("unused") val useOkhttp: Boolean, + val script: String?, + val scriptCallback: ((String) -> Unit)?, + val timeout: Long ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() - return chain.proceed(request) + return runBlocking { + val fixedRequest = resolveUsingWebView(request).first + chain.proceed(fixedRequest ?: request) + } } actual companion object { actual val DEFAULT_TIMEOUT = 60_000L actual var webViewUserAgent: String? = null + get() = field ?: DesktopWebView.engine?.userAgent?.also { field = it } + + /** The Android app's, which Android extensions call */ + @JvmName("getWebViewUserAgent1") + fun getWebViewUserAgent(): String? = webViewUserAgent } actual suspend fun resolveUsingWebView( @@ -64,11 +79,43 @@ actual class WebViewResolver actual constructor( } } + /** As on Android: stops at the first request matching [interceptUrl], collecting those matching [additionalUrls] */ actual suspend fun resolveUsingWebView( request: Request, requestCallBack: (Request) -> Boolean ): Pair<Request?, List<Request>> { - TODO("Not yet implemented") + val engine = DesktopWebView.engine + if (engine == null) { + Log.w(TAG, "No browser to load ${request.url}") + return null to emptyList() + } + var fixedRequest: Request? = null + val extraRequestList = Collections.synchronizedList(ArrayList<Request>()) + try { + engine.load(request, userAgent, script, scriptCallback, timeout) { seen -> + val url = seen.url.toString() + when { + interceptUrl.containsMatchIn(url) -> { + fixedRequest = seen + requestCallBack(seen) + true + } + + additionalUrls.any { it.containsMatchIn(url) } -> { + extraRequestList.add(seen) + requestCallBack(seen) + } + + else -> false + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logError(e) + } + engine.userAgent?.let { webViewUserAgent = it } + return fixedRequest to extraRequestList.toList() } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bb34aa31165..3877e602366 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -81,4 +81,8 @@ tasks.withType<Test>().configureEach { maxHeapSize = "3g" // Tests keep their files, such as the HTTP cache, away from the real app's folders systemProperty("cloudstream.home", layout.buildDirectory.dir("test-home").get().asFile.absolutePath) + // Tests that start the installed Edge or Chrome, off by default: gradlew :shared:jvmTest -PbrowserTests + systemProperty("cloudstream.browserTests", providers.gradleProperty("browserTests").isPresent.toString()) + systemProperty("cloudstream.cloudflareTest", providers.gradleProperty("cloudflareTest").isPresent.toString()) + systemProperty("cloudstream.cloudflareUrl", providers.gradleProperty("cloudflareUrl").getOrElse("")) } diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt new file mode 100644 index 00000000000..f05baa867aa --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt @@ -0,0 +1,507 @@ +package com.lagradost.cloudstream4.browser + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.lagradost.cloudstream3.network.DesktopWebView +import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.android.DesktopAndroid +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.io.File +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * The browser behind extensions' WebViewResolver and CloudflareKiller on desktop: Microsoft Edge, + * which comes with Windows, or Chrome, run with its own profile in the app's cache folder and driven + * through the DevTools protocol. Its window stays off screen, unless a page waits on Cloudflare's + * check for a few seconds: then it shows, so the user can tick the box. The browser closes after a + * few idle minutes, and when the app exits. + */ +class SystemBrowser( + private val executable: File, + private val profile: File = AppDirs.cache.resolve("browser").toFile(), + /** Where the window opens: off screen, unless someone needs to watch it */ + private val hidden: Boolean = true, +) : DesktopWebView { + private val json = jacksonObjectMapper() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val startLock = Mutex() + private val http = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build() + + private class Connection(val process: Process, val socket: WebSocket) + + @Volatile private var connection: Connection? = null + private val nextId = AtomicInteger(1) + private val pending = ConcurrentHashMap<Int, CompletableDeferred<JsonNode>>() + /** Events of each page, by its DevTools session */ + private val sessions = ConcurrentHashMap<String, Channel<JsonNode>>() + private val loads = AtomicInteger(0) + private var idleJob: Job? = null + + @Volatile override var userAgent: String? = null + private set + + /** Called with the browser's process id before its window comes forward, which Windows must allow */ + var onShow: ((Long) -> Unit)? = null + + override suspend fun load( + request: Request, + userAgent: String?, + script: String?, + onScript: ((String) -> Unit)?, + timeoutMs: Long, + onRequest: (Request) -> Boolean, + ) { + loads.incrementAndGet() + idleJob?.cancel() + try { + start() + val target = call("Target.createTarget", mapOf("url" to "about:blank")).path("targetId").asText() + try { + val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() + val events = Channel<JsonNode>(Channel.UNLIMITED) + sessions[session] = events + try { + browse(request, userAgent, script, onScript, timeoutMs, onRequest, target, session, events) + } finally { + sessions.remove(session) + events.close() + } + } finally { + runCatching { call("Target.closeTarget", mapOf("targetId" to target)) } + } + } finally { + if (loads.decrementAndGet() == 0) idleJob = scope.launch { + delay(IDLE_CLOSE_MS) + if (loads.get() == 0) stop() + } + } + } + + private suspend fun browse( + request: Request, + userAgent: String?, + script: String?, + onScript: ((String) -> Unit)?, + timeoutMs: Long, + onRequest: (Request) -> Boolean, + target: String, + session: String, + events: Channel<JsonNode>, + ) { + call("Network.enable", session = session) + if (userAgent != null) call("Network.setUserAgentOverride", mapOf("userAgent" to userAgent), session) + // Pictures, fonts and media are not needed to find links, as on Android + call("Network.setBlockedURLs", mapOf("urls" to BLOCKED), session) + val extra = request.headers.toMap().filterKeys { it.lowercase() !in setOf("user-agent", "referer", "cookie") } + if (extra.isNotEmpty()) call("Network.setExtraHTTPHeaders", mapOf("headers" to extra), session) + val referer = request.header("Referer") + call("Page.navigate", buildMap { put("url", request.url.toString()); if (referer != null) put("referrer", referer) }, session) + + val shown = CompletableDeferred<Unit>() + val checker = showWhenChallenged(request, target, session, shown) + try { + withTimeoutOrNull(timeoutMs) { + for (event in events) { + if (event.path("method").asText() != "Network.requestWillBeSent") continue + val sent = event.path("params").path("request") + val seen = toRequest(sent) ?: continue + if (script != null) { + runCatching { evaluateJson(script, session) }.getOrNull()?.let { onScript?.invoke(it) } + } + if (onRequest(seen)) break + } + } + } finally { + checker.cancel() + if (shown.isCompleted) runCatching { place(target, onScreen = false, session) } + } + } + + /** A page still on Cloudflare's check after a few seconds needs the user: the window comes on screen */ + private fun showWhenChallenged(request: Request, target: String, session: String, shown: CompletableDeferred<Unit>) = scope.launch { + delay(SHOW_AFTER_MS) + val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() + if (CHALLENGE_TITLES.any { title.contains(it, ignoreCase = true) }) { + DesktopAndroid.toastHandler?.invoke("${request.url.host} checks you are human: tick the box in the browser window") + runCatching { place(target, onScreen = true, session) } + shown.complete(Unit) + } + } + + /** Moves the page's window on screen for the user, or back off screen */ + private suspend fun place(target: String, onScreen: Boolean, session: String) { + val window = call("Browser.getWindowForTarget", mapOf("targetId" to target)).path("windowId").asInt() + val bounds = if (onScreen) mapOf("left" to 120, "top" to 80, "width" to 900, "height" to 720) else mapOf("left" to OFF_SCREEN, "top" to OFF_SCREEN) + call("Browser.setWindowBounds", mapOf("windowId" to window, "bounds" to bounds + ("windowState" to "normal"))) + if (onScreen) { + connection?.process?.pid()?.let { onShow?.invoke(it) } + call("Target.activateTarget", mapOf("targetId" to target)) + call("Page.bringToFront", session = session) + } + } + + private fun toRequest(sent: JsonNode): Request? = runCatching { + val url = sent.path("url").asText() + if (!url.startsWith("http")) return null + Request.Builder().url(url).apply { + sent.path("headers").properties().forEach { (name, value) -> header(name, value.asText()) } + val method = sent.path("method").asText("GET") + if (method != "GET") { + val body = sent.path("postData").asText("").toByteArray() + method(method, if (method == "HEAD") null else body.toRequestBody()) + } + }.build() + }.getOrNull() + + private suspend fun evaluate(expression: String, session: String): String? = + call("Runtime.evaluate", mapOf("expression" to expression, "returnByValue" to true), session).path("result").path("value").takeIf { !it.isMissingNode }?.asText() + + /** The script's result as JSON, like Android's evaluateJavascript */ + private suspend fun evaluateJson(expression: String, session: String): String { + val value = call("Runtime.evaluate", mapOf("expression" to expression, "returnByValue" to true, "awaitPromise" to true), session).path("result").path("value") + return if (value.isMissingNode) "null" else json.writeValueAsString(value) + } + + override suspend fun fetch(request: Request): Response? { + loads.incrementAndGet() + idleJob?.cancel() + try { + start() + val target = call("Target.createTarget", mapOf("url" to "about:blank")).path("targetId").asText() + try { + val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() + val events = Channel<JsonNode>(Channel.UNLIMITED) + sessions[session] = events + try { + return fetchIn(target, session, events, request) + } finally { + sessions.remove(session) + events.close() + } + } finally { + runCatching { call("Target.closeTarget", mapOf("targetId" to target)) } + } + } catch (e: Exception) { + println("WARN SystemBrowser: fetch of ${request.url} failed: $e") + return null + } finally { + if (loads.decrementAndGet() == 0) idleJob = scope.launch { + delay(IDLE_CLOSE_MS) + if (loads.get() == 0) stop() + } + } + } + + private suspend fun fetchIn(target: String, session: String, events: Channel<JsonNode>, request: Request): Response? = + if (request.method == "GET") navigateIn(target, session, events, request) else fetchInPage(session, events, request) + + /** Loads a GET request as the page itself, which Cloudflare trusts more than a script's fetch() */ + private suspend fun navigateIn(target: String, session: String, events: Channel<JsonNode>, request: Request): Response? { + val url = request.url + call("Network.enable", session = session) + call("Page.enable", session = session) + call("Network.setBlockedURLs", mapOf("urls" to BLOCKED), session) + val extra = request.headers.toMap().filterKeys { it.lowercase() !in setOf("user-agent", "referer", "cookie") } + if (extra.isNotEmpty()) call("Network.setExtraHTTPHeaders", mapOf("headers" to extra), session) + call("Page.navigate", buildMap { put("url", url.toString()); request.header("Referer")?.let { put("referrer", it) } }, session) + // The page's last document: Cloudflare's check marks its page, and loads the real one once passed + var document: JsonNode? = null + var finished = false + val shown = CompletableDeferred<Unit>() + val checker = showWhenChallenged(request, target, session, shown) + try { + withTimeoutOrNull(FETCH_TIMEOUT_MS) { + for (event in events) { + val params = event.path("params") + when (event.path("method").asText()) { + "Network.responseReceived" -> if (params.path("type").asText() == "Document") { + document = params + finished = false + } + + "Network.loadingFinished" -> if (params.path("requestId").asText() == document?.path("requestId")?.asText()) { + finished = true + } + + // Once the page is up, it is either the real one or Cloudflare's check, which loads the real one when passed + "Page.loadEventFired" -> if (finished) { + val marked = (document?.path("response")?.path("headers") as? ObjectNode)?.fields()?.asSequence() + ?.any { it.key.equals("cf-mitigated", ignoreCase = true) && it.value.asText() == "challenge" } == true + val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() + if (!marked && CHALLENGE_TITLES.none { title.contains(it, ignoreCase = true) }) break + } + } + } + } + } finally { + checker.cancel() + if (shown.isCompleted) runCatching { place(target, onScreen = false, session) } + } + val done = document?.takeIf { finished } ?: return null + val response = done.path("response") + val body = call("Network.getResponseBody", mapOf("requestId" to done.path("requestId").asText()), session) + val bytes = if (body.path("base64Encoded").asBoolean()) Base64.getDecoder().decode(body.path("body").asText()) + else body.path("body").asText().toByteArray() + val headers = Headers.Builder().apply { + (response.path("headers") as? ObjectNode)?.fields()?.forEach { (name, value) -> + // Several values of a header come joined by new lines + value.asText().split('\n').forEach { runCatching { addUnsafeNonAscii(name, it) } } + } + // The body is already decoded + removeAll("Content-Encoding") + removeAll("Content-Length") + }.build() + println("INFO SystemBrowser: loaded $url in the page: ${response.path("status").asInt()}") + return Response.Builder() + .request(request.newBuilder().url(response.path("url").asText(url.toString())).build()) + .protocol(Protocol.HTTP_1_1) + .code(response.path("status").asInt()) + .message(response.path("statusText").asText("")) + .headers(headers) + .body(bytes.toResponseBody(headers["Content-Type"]?.toMediaTypeOrNull())) + .build() + } + + private suspend fun fetchInPage(session: String, events: Channel<JsonNode>, request: Request): Response? { + // fetch() only reaches the page's own site: a small file of it is opened first + val url = request.url + val origin = "${url.scheme}://${url.host}" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else "" + call("Page.enable", session = session) + call("Page.navigate", mapOf("url" to "$origin/robots.txt"), session) + withTimeoutOrNull(20_000) { + for (event in events) if (event.path("method").asText() == "Page.loadEventFired") break + } + val init = buildMap<String, Any?> { + put("method", request.method) + put("credentials", "include") + put("headers", request.headers.toMap().filterKeys { it.lowercase() !in FORBIDDEN_FETCH_HEADERS }) + request.header("Referer")?.let { put("referrer", it) } + request.body?.let { body -> + val buffer = okio.Buffer() + body.writeTo(buffer) + put("body", buffer.readUtf8()) + } + } + val script = """ + (async () => { + const r = await fetch(${json.writeValueAsString(url.toString())}, ${json.writeValueAsString(init)}); + const bytes = new Uint8Array(await r.arrayBuffer()); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + return { status: r.status, url: r.url, headers: [...r.headers.entries()], body: btoa(binary) }; + })() + """.trimIndent() + val evaluated = call("Runtime.evaluate", mapOf("expression" to script, "returnByValue" to true, "awaitPromise" to true), session) + val result = evaluated.path("result").path("value") + if (result.isMissingNode || !result.has("status")) { + println("WARN SystemBrowser: fetch of $url failed in the page: ${evaluated.path("exceptionDetails").path("exception").path("description").asText(evaluated.toString().take(300))}") + return null + } + println("INFO SystemBrowser: fetched $url in the page: ${result.path("status").asInt()}") + val headers = Headers.Builder().apply { + result.path("headers").forEach { pair -> runCatching { addUnsafeNonAscii(pair[0].asText(), pair[1].asText()) } } + }.build() + val body = Base64.getDecoder().decode(result.path("body").asText()) + return Response.Builder() + .request(request.newBuilder().url(result.path("url").asText(url.toString())).build()) + .protocol(Protocol.HTTP_1_1) + .code(result.path("status").asInt()) + .message("") + .headers(headers) + .body(body.toResponseBody(headers["Content-Type"]?.toMediaTypeOrNull())) + .build() + } + + override fun cookies(url: String): String? { + val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return null + if (connection == null) return null + val all = runBlocking { withTimeout(10_000) { call("Storage.getCookies") } }.path("cookies") + val matching = all.filter { cookie -> + val domain = cookie.path("domain").asText().trimStart('.') + host == domain || host.endsWith(".$domain") + } + if (matching.isEmpty()) return null + return matching.joinToString("; ") { "${it.path("name").asText()}=${it.path("value").asText()}" } + } + + /** Sends one DevTools command and waits for its result */ + private suspend fun call(method: String, params: Map<String, Any?> = emptyMap(), session: String? = null): JsonNode { + val socket = connection?.socket ?: throw IllegalStateException("The browser is not running") + val id = nextId.getAndIncrement() + val answer = CompletableDeferred<JsonNode>() + pending[id] = answer + val message = buildMap { put("id", id); put("method", method); put("params", params); if (session != null) put("sessionId", session) } + if (!socket.send(json.writeValueAsString(message))) { + pending.remove(id) + throw IllegalStateException("The browser closed") + } + val reply = try { + withTimeout(CALL_TIMEOUT_MS) { answer.await() } + } finally { + pending.remove(id) + } + reply.path("error").takeIf { !it.isMissingNode }?.let { throw IllegalStateException("$method: ${it.path("message").asText()}") } + return reply.path("result") + } + + private suspend fun start() = startLock.withLock { + connection?.takeIf { it.process.isAlive }?.let { return } + connection = null + profile.mkdirs() + val portFile = profile.resolve("DevToolsActivePort") + portFile.delete() + val process = ProcessBuilder( + executable.absolutePath, + "--remote-debugging-port=0", + "--user-data-dir=${profile.absolutePath}", + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-sync", + "--hide-crash-restore-bubble", + "--disable-features=msImplicitSignin,msEdgeWelcomePage", + if (hidden) "--window-position=$OFF_SCREEN,$OFF_SCREEN" else "--window-position=120,80", + "--window-size=1100,800", + "about:blank", + ).redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start() + + // The browser writes its DevTools port and address once it listens + val lines = withTimeoutOrNull(START_TIMEOUT_MS) { devToolsAddress(portFile, process) } + if (lines == null) { + process.destroyForcibly() + throw IllegalStateException("${executable.name} did not start") + } + val opened = CompletableDeferred<Unit>() + val socket = http.newWebSocket( + Request.Builder().url("ws://127.0.0.1:${lines[0].trim()}${lines[1].trim()}").build(), + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + opened.complete(Unit) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val message = runCatching { json.readTree(text) }.getOrNull() ?: return + val id = message.path("id") + if (!id.isMissingNode) { + pending[id.asInt()]?.complete(message) + } else { + message.path("sessionId").asText(null)?.let { sessions[it]?.trySend(message) } + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + opened.completeExceptionally(t) + failAll(t) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + failAll(IllegalStateException("The browser closed")) + } + }, + ) + withTimeout(START_TIMEOUT_MS) { opened.await() } + connection = Connection(process, socket) + userAgent = runCatching { call("Browser.getVersion").path("userAgent").asText() }.getOrNull() + println("INFO SystemBrowser: started ${executable.name}, $userAgent") + } + + private suspend fun devToolsAddress(portFile: File, process: Process): List<String>? { + while (process.isAlive) { + val lines = runCatching { portFile.readLines() }.getOrNull() + if (lines != null && lines.size >= 2) return lines + delay(100) + } + return null + } + + private fun failAll(error: Throwable) { + connection = null + pending.values.forEach { it.completeExceptionally(error) } + sessions.values.forEach { it.close() } + } + + /** Closes the browser, also when the app exits */ + fun stop() { + val current = connection ?: return + connection = null + runCatching { current.socket.send("""{"id":0,"method":"Browser.close"}""") } + if (!current.process.waitFor(3, TimeUnit.SECONDS)) { + current.process.descendants().forEach { it.destroyForcibly() } + current.process.destroyForcibly() + } + current.socket.cancel() + } + + private fun JsonNode.properties(): List<Pair<String, JsonNode>> = + (this as? ObjectNode)?.fields()?.asSequence()?.map { it.key to it.value }?.toList().orEmpty() + + companion object { + private const val OFF_SCREEN = -32000 + private const val SHOW_AFTER_MS = 5_000L + private const val CALL_TIMEOUT_MS = 30_000L + private const val START_TIMEOUT_MS = 20_000L + private const val IDLE_CLOSE_MS = 3 * 60_000L + private const val FETCH_TIMEOUT_MS = 60_000L + /** Headers a page's fetch() may not set: the browser sends its own */ + private val FORBIDDEN_FETCH_HEADERS = setOf( + "user-agent", "cookie", "referer", "origin", "host", "content-length", "connection", "accept-encoding", + "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", + ) + private val CHALLENGE_TITLES = listOf("Just a moment", "Attention Required", "Un instant") + private val BLOCKED = listOf( + "*.jpg", "*.jpeg", "*.png", "*.webp", "*.gif", "*.gifv", "*.mp4", "*.mp3", "*.webm", "*.mkv", + "*.avi", "*.mov", "*.flv", "*.ogg", "*.wav", "*.woff", "*.woff2", "*.ttf", "*.ts", "*.vtt", "*.srt", + "*/favicon.ico", + ) + + /** Edge, which comes with Windows, else Chrome or Chromium. Null when none is installed */ + fun find(): File? { + val env = System.getenv() + val windows = listOfNotNull(env["ProgramFiles(x86)"], env["ProgramFiles"], env["LOCALAPPDATA"]).flatMap { base -> + listOf("Microsoft\\Edge\\Application\\msedge.exe", "Google\\Chrome\\Application\\chrome.exe").map { File(base, it) } + } + val others = listOf( + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/microsoft-edge", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", + ).map(::File) + return (windows + others).firstOrNull { it.isFile } + } + + /** Installs the browser for extensions, when one is found */ + fun install(): SystemBrowser? { + val executable = find() ?: return null.also { println("WARN SystemBrowser: no Edge or Chrome found, extensions cannot use a WebView") } + val browser = SystemBrowser(executable) + DesktopWebView.engine = browser + Runtime.getRuntime().addShutdownHook(Thread { browser.stop() }) + return browser + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt new file mode 100644 index 00000000000..e04274b02a6 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt @@ -0,0 +1,90 @@ +package com.lagradost.cloudstream4.browser + +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.network.CloudflareKiller +import com.lagradost.cloudstream3.network.DesktopWebView +import com.lagradost.cloudstream3.network.WebViewResolver +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import java.net.InetSocketAddress +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Drives the installed Edge or Chrome, so it only runs when asked for with + * -Dcloudstream.browserTests=true (gradle: -PbrowserTests), on a machine with one of them. + */ +class SystemBrowserTest { + private val enabled = System.getProperty("cloudstream.browserTests") == "true" && SystemBrowser.find() != null + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/player") { exchange -> + val page = """ + <html><head><title>Player + + + """.trimIndent().toByteArray() + exchange.responseHeaders.add("Content-Type", "text/html") + exchange.sendResponseHeaders(200, page.size.toLong()) + exchange.responseBody.use { it.write(page) } + } + createContext("/stream") { exchange -> + exchange.sendResponseHeaders(200, 0) + exchange.responseBody.close() + } + start() + } + private val base = "http://127.0.0.1:${server.address.port}" + private val browser = if (enabled) SystemBrowser(SystemBrowser.find()!!, Files.createTempDirectory("cs-browser").toFile()) else null + + @AfterTest + fun stop() { + server.stop(0) + browser?.stop() + DesktopWebView.engine = null + } + + @Test + fun findsTheRequestAPageMakesAndItsCookies() = runBlocking { + if (browser == null) return@runBlocking + DesktopWebView.engine = browser + val (found, extra) = WebViewResolver(Regex("""master\.m3u8"""), additionalUrls = listOf(Regex("/player")), timeout = 30_000) + .resolveUsingWebView("$base/player", referer = "https://referer.invalid/") + assertNotNull(found, "the page's request for the stream") + assertEquals("$base/stream/master.m3u8?token=abc", found.url.toString()) + assertEquals("abc", found.header("X-Token")) + assertEquals(listOf("$base/player"), extra.map { it.url.toString() }) + assertTrue(browser.cookies("$base/player").orEmpty().contains("seen=yes")) + assertTrue(WebViewResolver.webViewUserAgent.orEmpty().contains("Mozilla")) + } + + /** A test page behind Cloudflare's check (it answers 403 to plain clients), so only with -PcloudflareTest as well */ + @Test + fun passesCloudflaresCheck() = runBlocking { + if (browser == null || System.getProperty("cloudstream.cloudflareTest") != "true") return@runBlocking + DesktopWebView.engine = browser + val url = System.getProperty("cloudstream.cloudflareUrl").orEmpty().ifBlank { "https://www.scrapingcourse.com/cloudflare-challenge" } + val direct = app.get(url, timeout = 30) + println("Without the browser: ${direct.code} ${direct.headers["Server"]}") + val response = app.get(url, interceptor = CloudflareKiller(), timeout = 90) + val heading = Regex("(.*?)|]*>(.*?)").findAll(response.text).joinToString(" / ") { it.value } + println("With the browser: ${response.code}: $heading") + assertEquals(200, response.code) + } + + @Test + fun withoutABrowserNothingIsFound() = runBlocking { + DesktopWebView.engine = null + val (found, extra) = WebViewResolver(Regex("x")).resolveUsingWebView("$base/player") + assertNull(found) + assertTrue(extra.isEmpty()) + } +} From 8a75135fc0cc49cdcd21d5e7d66d6811ad4427ca Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 14:53:59 +0530 Subject: [PATCH 22/64] Desktop: stream torrents and magnet links through TorrServer Torrent and magnet links, and torrent titles, now play. On first use the player asks, as the Android app does, then downloads TorrServer's pinned Windows release (checked against its SHA-256), starts it on localhost and plays the torrent's largest video through mpv. Torrent links rank after every other link, and are dropped when the player closes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 5 +- .../cloudstream4/player/PlayerScreen.kt | 87 +++++++- shared/build.gradle.kts | 2 + .../cloudstream4/detail/DetailViewModel.kt | 5 +- .../cloudstream4/player/LinksViewModel.kt | 24 ++- .../cloudstream4/torrent/TorrServer.kt | 201 ++++++++++++++++++ .../cloudstream4/PlaybackViewModelTest.kt | 21 ++ .../cloudstream4/torrent/TorrServerTest.kt | 49 +++++ 8 files changed, 383 insertions(+), 11 deletions(-) create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/torrent/TorrServer.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/torrent/TorrServerTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 654793d14b5..2ddf0b8d49d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -64,6 +64,7 @@ import com.lagradost.cloudstream4.search.DataStoreSearchHistory import com.lagradost.cloudstream4.search.SearchViewModel import com.lagradost.cloudstream4.settings.ExtensionsScreen import com.lagradost.cloudstream4.settings.SettingsHomeScreen +import com.lagradost.cloudstream4.torrent.TorrServer import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.combine @@ -325,14 +326,14 @@ private fun PageContent(page: Page, navigator: AppNavigator) { } when (page) { is Page.Details -> DetailScreen( - viewModel = viewModel { DetailViewModel(api, page.url, DataStoreWatchStore.instance) }, + viewModel = viewModel { DetailViewModel(api, page.url, DataStoreWatchStore.instance, torrents = TorrServer.supported) }, onBack = { navigator.back() }, onPlay = navigator::play, onOpen = navigator::openDetails, ) is Page.Player -> PlayerScreen( - viewModel = viewModel { LinksViewModel(api, page.request) }, + viewModel = viewModel { LinksViewModel(api, page.request, torrents = TorrServer.supported) }, fullscreen = navigator.fullscreen, onBack = { navigator.back() }, onToggleFullscreen = navigator::toggleFullscreen, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index b0cdb443db8..865ec51677c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -23,7 +23,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.Button import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -87,6 +89,9 @@ import com.lagradost.cloudstream4.library.DataStoreWatchStore import com.lagradost.cloudstream4.library.saveProgress import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream4.torrent.TorrServer import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import java.io.File @@ -153,17 +158,53 @@ fun PlayerScreen( val playedOnLink = remember { doubleArrayOf(0.0, -1.0) } val currentState by rememberUpdatedState(state) + // Torrents stream through TorrServer, once the user agreed to it this session: mpv plays the + // stream's url, which failures are reported back from to the torrent's own link + val torrents = TorrServer.instance + var torrentAccepted by remember { mutableStateOf(TorrServer.accepted) } + var torrentStatus by remember { mutableStateOf(null) } + val torrentLinks = remember { HashMap() } + val torrentHashes = remember { mutableSetOf() } + DisposableEffect(Unit) { + onDispose { + val hashes = torrentHashes.toList() + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) + kotlinx.coroutines.GlobalScope.launch { hashes.forEach { torrents.drop(it) } } + } + } + // A new link was picked, by hand or because the last one failed. The subtitle picked stays on val selected = state.selectedLink - LaunchedEffect(selected?.url) { - if (selected == null) return@LaunchedEffect + val askTorrent = selected != null && TorrServer.isTorrent(selected) && !torrentAccepted + LaunchedEffect(selected?.url, torrentAccepted) { + if (selected == null || askTorrent) return@LaunchedEffect playedOnLink[0] = 0.0 playedOnLink[1] = -1.0 + val playing = if (TorrServer.isTorrent(selected)) { + try { + if (!torrents.installed) torrents.install { done, total -> + torrentStatus = if (total > 0) "Downloading TorrServer ${done * 100 / total}%" else "Downloading TorrServer" + } + torrentStatus = "Connecting to peers…" + val stream = torrents.stream(selected) + torrentHashes += stream.hash + torrentLinks[stream.url] = selected.url + @Suppress("DEPRECATION") + ExtractorLink(selected.source, selected.name, stream.url, "", selected.quality, type = ExtractorLinkType.VIDEO) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + viewModel.onAction(LinksAction.Failed(selected.url, e.message ?: "The torrent could not start")) + return@LaunchedEffect + } finally { + torrentStatus = null + } + } else selected val choice = subtitleChoice val subtitles = state.subtitles.mapNotNull { sub -> downloaded[sub.url]?.let { MpvSubtitle(it, sub.lang, select = choice == SubtitleChoice.Extension(sub.url)) } } - player.play(selected, playedTo[0].takeIf { it > 0 }?.let { it / 1000.0 }, subtitles) + player.play(playing, playedTo[0].takeIf { it > 0 }?.let { it / 1000.0 }, subtitles) if (choice == SubtitleChoice.Off) player.hideSubtitlesWhenLoaded() } // In full screen the bars show on mouse moves and hide once the mouse rests, unless a panel is open @@ -196,7 +237,7 @@ fun PlayerScreen( MpvEvent.ToggleFullscreen -> currentFullscreen() MpvEvent.Back -> currentBack() MpvEvent.Click -> if (panel != null) panel = null else player.togglePause() - is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(event.url, event.message)) + is MpvEvent.Failed -> viewModel.onAction(LinksAction.Failed(torrentLinks[event.url] ?: event.url, event.message)) MpvEvent.Ended -> { val url = currentState.selected // A file that stops within seconds of starting is broken: it must not count as watched @@ -228,6 +269,8 @@ fun PlayerScreen( // While links are found and the video opens or buffers, a spinner turns over the video val indicator = when { + torrentStatus != null -> torrentStatus + askTorrent -> null state.exhausted -> null state.selected == null -> if (state.links.isEmpty()) "Finding links…" else "Finding links · ${state.links.size} found" status.buffering -> status.bufferPercent?.takeIf { it in 1..99 }?.let { "Buffering $it%" } ?: "Loading…" @@ -330,7 +373,7 @@ fun PlayerScreen( title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), line = subtitleMessage ?: when { state.exhausted && state.links.isEmpty() -> "No playable links found" + - if (state.skipped > 0) " (${state.skipped} torrent or DRM links cannot play on desktop)" else "" + if (state.skipped > 0) " (${state.skipped} links cannot play on desktop, such as DRM)" else "" state.exhausted -> "None of the ${state.links.size} links could be played" state.selected == null -> "Finding links… ${state.links.size} found" else -> state.selectedLink?.let(LinksViewModel::label) @@ -344,6 +387,13 @@ fun PlayerScreen( // The video is a native window, so nothing can be drawn over it: panels open beside it Row(Modifier.fillMaxWidth().weight(1f)) { SwingPanel(factory = { player.canvas }, modifier = Modifier.weight(1f).fillMaxHeight(), background = Color.Black) + if (askTorrent) TorrentPrompt( + onPlay = { + TorrServer.accepted = true + torrentAccepted = true + }, + onSkip = { selected?.let { viewModel.onAction(LinksAction.Failed(it.url, "Torrent not played")) } }, + ) val open = panel if (open != null && showBars) SidePanel( panel = open, @@ -378,6 +428,33 @@ fun PlayerScreen( } } +/** + * Asks before the first torrent of the session, as the Android app does: playing one shares it with + * other people, who see the user's IP address, and needs TorrServer the first time + */ +@Composable +private fun TorrentPrompt(onPlay: () -> Unit, onSkip: () -> Unit) { + Column( + Modifier.width(360.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Text("Play a torrent?", style = MaterialTheme.typography.titleLarge) + Text( + "This source is a torrent. It plays while it downloads from other people sharing it, and while it " + + "plays your connection shares it too: they can see your IP address.", + style = MaterialTheme.typography.bodyMedium, + ) + if (!TorrServer.instance.installed) Text( + "The first time, CloudStream downloads TorrServer (63 MB, github.com/YouROK/TorrServer) to stream it. " + + "Windows may then ask whether TorrServer can use the network: it plays either way, Allow lets more people connect.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text("Play torrent") } + OutlinedButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { Text("Use another source") } + } +} + /** "English", or "English 1", "English 2" when there are several, by url */ private fun subtitleLabels(subtitles: List): Map { // Extensions spell a language with stray spaces at times, which must not make it a second language diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index bb34aa31165..1f99d28ab7c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -81,4 +81,6 @@ tasks.withType().configureEach { maxHeapSize = "3g" // Tests keep their files, such as the HTTP cache, away from the real app's folders systemProperty("cloudstream.home", layout.buildDirectory.dir("test-home").get().asFile.absolutePath) + // Downloads TorrServer and streams a public torrent, off by default: gradlew :shared:jvmTest -PtorrentTests + systemProperty("cloudstream.torrentTests", providers.gradleProperty("torrentTests").isPresent.toString()) } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt index 770a5aa7a89..82bc327fc25 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt @@ -97,6 +97,8 @@ class DetailViewModel( private val api: MainAPI, private val url: String, private val store: WatchStore = InMemoryWatchStore(), + /** Torrent titles can play */ + private val torrents: Boolean = false, ) : ViewModel(), StateContainer by DefaultStateContainer(DetailState()), ActionHandler { @@ -185,12 +187,13 @@ class DetailViewModel( movieData = when (response) { is MovieLoadResponse -> response.dataUrl is LiveStreamLoadResponse -> response.dataUrl + is TorrentLoadResponse -> if (torrents) (response.torrent ?: response.magnet)?.takeIf { it.isNotBlank() } else null else -> null }, dubs = dubs, selectedDub = dub, seasons = groupSeasons(response, episodesByDub[dub].orEmpty()), - unsupported = if (response is TorrentLoadResponse) "Torrents cannot be played on desktop yet." else null, + unsupported = if (response is TorrentLoadResponse && !torrents) "Torrents cannot be played on desktop yet." else null, titleId = titleId, episodeIds = WatchIds.episodeIds(response, titleId), )) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt index 6692fb10720..365bed99320 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt @@ -29,7 +29,7 @@ data class LinksState( val selected: String? = null, /** Links that failed to play, by url, with the reason */ val failed: Map = emptyMap(), - /** Links found that the desktop player cannot use, such as torrents or DRM */ + /** Links found that the player cannot use, such as DRM, or torrents where they cannot play */ val skipped: Int = 0, ) { val selectedLink: ExtractorLink? get() = links.firstOrNull { it.url == selected } @@ -54,6 +54,8 @@ class LinksViewModel( private val api: MainAPI, val request: PlayRequest, private val autoPickAfter: Duration = 6.seconds, + /** Torrents and magnet links can play, after every other link */ + private val torrents: Boolean = false, ) : ViewModel(), StateContainer by DefaultStateContainer(LinksState()), ActionHandler { private var loadJob: Job? = null @@ -89,6 +91,8 @@ class LinksViewModel( delay(autoPickAfter) pickIfNone() } + // A torrent title's data is its magnet or .torrent link, which the extension may not repeat + if (torrents) torrentLink(request.data)?.let(::addLink) api.loadLinksSafely( request.data, onSubtitle = { sub -> updateState { if (subtitles.any { it.url == sub.url }) this else copy(subtitles = subtitles + sub) } }, @@ -102,13 +106,14 @@ class LinksViewModel( } private fun addLink(link: ExtractorLink) { - val playable = link !is DrmExtractorLink && link.type != ExtractorLinkType.TORRENT && link.type != ExtractorLinkType.MAGNET + val playable = link !is DrmExtractorLink && (torrents || !isTorrent(link)) updateState { when { !playable -> copy(skipped = skipped + 1) links.any { it.url == link.url } -> this else -> { - val links = (links + link).sortedByDescending { rank(it.quality) } + // Torrents last: they take a while to start and share the user's connection + val links = (links + link).sortedWith(compareBy { isTorrent(it) }.thenByDescending { rank(it.quality) }) copy(links = links, selected = selected ?: if (autoPicking) nextLink(links, failed) else null) } } @@ -124,6 +129,19 @@ class LinksViewModel( private fun nextLink(links: List, failed: Map) = links.firstOrNull { it.url !in failed }?.url + fun isTorrent(link: ExtractorLink) = link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.MAGNET + + /** The link a torrent title's data is, as the Android app plays it */ + private fun torrentLink(data: String): ExtractorLink? { + val type = when { + data.startsWith("magnet:") -> ExtractorLinkType.MAGNET + data.substringBefore('?').endsWith(".torrent") -> ExtractorLinkType.TORRENT + else -> return null + } + @Suppress("DEPRECATION") + return ExtractorLink("Torrent", "Torrent", data, "", Qualities.Unknown.value, type = type) + } + /** Higher is better. An unknown quality ranks with 480p, as on Android */ fun rank(quality: Int): Int = if (quality == Qualities.Unknown.value) Qualities.P480.value else quality diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/torrent/TorrServer.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/torrent/TorrServer.kt new file mode 100644 index 00000000000..cc53a45f57a --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/torrent/TorrServer.kt @@ -0,0 +1,201 @@ +package com.lagradost.cloudstream4.torrent + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream4.AppDirs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.io.IOException +import java.net.ServerSocket +import java.net.URLEncoder +import java.security.MessageDigest +import java.util.concurrent.TimeUnit + +/** A torrent being streamed: where the player reads it, and what to drop when done */ +data class TorrentStream(val url: String, val hash: String, val fileName: String) + +/** + * Streams torrents and magnet links, as the Android app does with its built-in TorrServer. Desktop + * runs TorrServer's own Windows program (GPL-3.0, https://github.com/YouROK/TorrServer), downloaded + * once on first use and checked against its published SHA-256. It listens on this computer only, + * and the player reads the torrent from it as an HTTP stream while it downloads. + */ +class TorrServer( + private val dir: File = AppDirs.cache.resolve("torrserver").toFile(), + private val download: DownloadSource = DownloadSource.WINDOWS_AMD64, +) { + /** A pinned release of TorrServer, with the digest GitHub shows for it */ + data class DownloadSource(val url: String, val sha256: String, val fileName: String) { + companion object { + private const val VERSION = "MatriX.145.1" + val WINDOWS_AMD64 = DownloadSource( + "https://github.com/YouROK/TorrServer/releases/download/$VERSION/TorrServer-windows-amd64.exe", + "d8bf93aba0521f2450436d4ede02c3b6e44a37b52c386aa9e6206daa8e5551ed", + "TorrServer-$VERSION.exe", + ) + } + } + + private val json = jacksonObjectMapper() + private val http = OkHttpClient.Builder().readTimeout(30, TimeUnit.SECONDS).build() + private val lock = Mutex() + @Volatile private var process: Process? = null + @Volatile private var base: String? = null + + val executable: File get() = dir.resolve(download.fileName) + + /** TorrServer is downloaded already */ + val installed: Boolean get() = executable.isFile + + /** Downloads TorrServer, reporting bytes done and the full size */ + suspend fun install(progress: (Long, Long) -> Unit = { _, _ -> }) = withContext(Dispatchers.IO) { + if (installed) return@withContext + dir.mkdirs() + val part = dir.resolve("${download.fileName}.part") + val digest = MessageDigest.getInstance("SHA-256") + http.newCall(Request.Builder().url(download.url).build()).execute().use { response -> + if (!response.isSuccessful) throw IOException("TorrServer could not be downloaded: HTTP ${response.code}") + val total = response.body.contentLength() + var done = 0L + part.outputStream().use { out -> + response.body.byteStream().use { input -> + val buffer = ByteArray(256 * 1024) + while (true) { + ensureActive() + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + digest.update(buffer, 0, read) + done += read + progress(done, total) + } + } + } + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (actual != download.sha256) { + part.delete() + throw IOException("TorrServer's download did not match its published checksum") + } + if (!part.renameTo(executable)) throw IOException("TorrServer could not be saved to $dir") + } + + /** + * Adds a torrent or magnet link and returns where the player can stream it: the file the link + * names with `index=`, otherwise the largest video. Waits for the torrent's file list, which + * comes from its peers. + */ + suspend fun stream(link: ExtractorLink): TorrentStream = withContext(Dispatchers.IO) { + val server = start() + val added = post(server, mapOf("action" to "add", "link" to link.url, "title" to link.name, "save_to_db" to false)) + val hash = added.path("hash").asText().ifEmpty { throw IOException("TorrServer did not take the link") } + val files = withTimeoutOrNull(METADATA_TIMEOUT_MS) { files(server, hash) } ?: run { + drop(hash) + throw IOException("No peers sent the torrent's file list within a minute") + } + val wanted = Regex("""[?&]index=(\d+)""").find(link.url)?.groupValues?.get(1)?.toIntOrNull() + val file = files.firstOrNull { wanted != null && it.path("id").asInt() == wanted } + ?: files.filter { it.path("path").asText().substringAfterLast('.').lowercase() in VIDEO_EXTENSIONS }.maxByOrNull { it.path("length").asLong() } + ?: files.maxBy { it.path("length").asLong() } + val path = file.path("path").asText() + val name = URLEncoder.encode(path.substringAfterLast('/'), "UTF-8").replace("+", "%20") + TorrentStream("$server/stream/$name?link=$hash&index=${file.path("id").asInt()}&play", hash, path) + } + + /** The torrent's files, once its peers sent the list */ + private suspend fun files(server: String, hash: String): List { + while (true) { + val status = post(server, mapOf("action" to "get", "hash" to hash)) + status.path("file_stats").takeIf { it.isArray && it.size() > 0 }?.let { return it.toList() } + delay(500) + } + } + + /** Stops downloading a torrent and forgets it */ + suspend fun drop(hash: String) = withContext(Dispatchers.IO) { + val server = base ?: return@withContext + runCatching { post(server, mapOf("action" to "drop", "hash" to hash)) } + runCatching { post(server, mapOf("action" to "rem", "hash" to hash)) } + } + + private fun post(server: String, body: Map): JsonNode { + val request = Request.Builder().url("$server/torrents") + .post(json.writeValueAsString(body).toRequestBody("application/json".toMediaType())).build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("TorrServer: HTTP ${response.code} for ${body["action"]}") + val text = response.body.string() + return if (text.isBlank()) json.createObjectNode() else json.readTree(text) + } + } + + /** Starts TorrServer on a free port, listening on this computer only */ + private suspend fun start(): String = lock.withLock { + val running = base + if (running != null && process?.isAlive == true) return running + if (!installed) throw IOException("TorrServer is not installed") + val port = ServerSocket(0).use { it.localPort } + val data = dir.resolve("data").apply { mkdirs() } + val started = ProcessBuilder(executable.absolutePath, "-p", port.toString(), "-i", "127.0.0.1", "-d", data.absolutePath) + .directory(dir) + .redirectErrorStream(true) + .redirectOutput(dir.resolve("torrserver.log")) + .start() + process = started + val server = "http://127.0.0.1:$port" + val up = withTimeoutOrNull(START_TIMEOUT_MS) { + while (started.isAlive) { + val echo = runCatching { http.newCall(Request.Builder().url("$server/echo").build()).execute().use { it.isSuccessful } }.getOrDefault(false) + if (echo) return@withTimeoutOrNull true + delay(200) + } + false + } ?: false + if (!up) { + started.destroyForcibly() + throw IOException("TorrServer did not start, see ${dir.resolve("torrserver.log")}") + } + base = server + println("INFO TorrServer: started on $server") + server + } + + /** Stops TorrServer, also when the app exits */ + fun stop() { + val running = process ?: return + process = null + base = null + running.descendants().forEach { it.destroy() } + running.destroy() + if (!running.waitFor(3, TimeUnit.SECONDS)) running.destroyForcibly() + } + + companion object { + private const val METADATA_TIMEOUT_MS = 60_000L + private const val START_TIMEOUT_MS = 20_000L + private val VIDEO_EXTENSIONS = setOf("mkv", "mp4", "avi", "mov", "webm", "m4v", "ts", "wmv", "flv", "mpg", "mpeg") + + fun isTorrent(link: ExtractorLink) = link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.MAGNET + + /** Only on Windows for now: TorrServer publishes a program per system */ + val supported: Boolean get() = System.getProperty("os.name").orEmpty().startsWith("Windows") + + /** Whether the user agreed to stream torrents in this session, as the Android app asks */ + @Volatile var accepted = false + + val instance: TorrServer by lazy { + TorrServer().also { server -> Runtime.getRuntime().addShutdownHook(Thread { server.stop() }) } + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt index 250791848d6..cc16af3443e 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt @@ -174,6 +174,27 @@ class PlaybackViewModelTest { assertEquals("hd · 1080p", LinksViewModel.label(state.links[0])) } + @Test + fun whereTorrentsCanPlayTheyComeAfterEveryOtherLink() { + api.torrents = 2 + api.links = listOf("low" to Qualities.P360.value) + val vm = LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data"), 50.milliseconds, torrents = true) + val state = await(vm.state) { !it.loading } + + assertEquals(listOf("low", "Torrent", "Torrent"), state.links.map { it.name }) + assertEquals("low", state.selectedLink?.name) + assertEquals(0, state.skipped) + + val title = await(DetailViewModel(api, "https://example.invalid/torrent", torrents = true).state) { it.status == DetailStatus.Done } + assertNull(title.unsupported) + assertEquals("magnet:?xt=x", title.movieData) + // The title's magnet plays even when the extension gives no link for it + api.torrents = 0 + api.links = emptyList() + val magnet = LinksViewModel(api, PlayRequest("Catalog", "A torrent", null, "magnet:?xt=x"), 50.milliseconds, torrents = true) + assertEquals("magnet:?xt=x", await(magnet.state) { !it.loading }.selectedLink?.url) + } + @Test fun aFailedLinkMovesOnToTheNextUntilNoneAreLeft() { val vm = links("a" to 1080, "b" to 720) diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/torrent/TorrServerTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/torrent/TorrServerTest.kt new file mode 100644 index 00000000000..c30491d20e9 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/torrent/TorrServerTest.kt @@ -0,0 +1,49 @@ +package com.lagradost.cloudstream4.torrent + +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Downloads TorrServer (63 MB, kept in the build folder) and streams a public torrent, so it only + * runs with -Dcloudstream.torrentTests=true (gradle: -PtorrentTests) on Windows. + */ +class TorrServerTest { + private val enabled = System.getProperty("cloudstream.torrentTests") == "true" && TorrServer.supported + private val server = TorrServer(File(System.getProperty("cloudstream.home"), "torrserver")) + + @AfterTest + fun stop() = server.stop() + + @Test + fun streamsAMagnetLink() = runBlocking { + if (!enabled) return@runBlocking + server.install() + assertTrue(server.installed) + // Big Buck Bunny (CC BY 3.0), from WebTorrent's examples + val magnet = "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny" + + "&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexplodie.org%3A6969" + + "&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com" + + "&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F" + val stream = server.stream(newExtractorLink("Test", "Big Buck Bunny", magnet, ExtractorLinkType.MAGNET)) + println("Streaming ${stream.fileName} from ${stream.url}") + assertTrue(stream.fileName.endsWith(".mp4"), stream.fileName) + val http = OkHttpClient.Builder().readTimeout(120, TimeUnit.SECONDS).build() + http.newCall(Request.Builder().url(stream.url).header("Range", "bytes=0-1048575").build()).execute().use { response -> + assertEquals(206, response.code) + val bytes = response.body.bytes() + assertEquals(1_048_576, bytes.size) + // An MP4 starts with an "ftyp" box + assertEquals("ftyp", String(bytes, 4, 4)) + } + server.drop(stream.hash) + } +} From 2435c7c0e7bd8aa13816377c96572761d9930b8d Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 15:34:21 +0530 Subject: [PATCH 23/64] Desktop: safer plugin installs, from the review of 938355fa - A plugin's internalName must be a plain file name: repositories cannot place plugin files outside the plugins folder. - An update is converted beside the old version, which keeps running if the conversion fails. - Only converted Android plugins get stubs; a desktop jar's missing class fails plainly. - The converted jar's manifest is written by path, not a jar: URI. - The Apache 2.0 and ASM licence texts ship with the app. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/AndroidPluginClassLoader.kt | 8 +- .../cloudstream4/android/DexConverter.kt | 4 +- .../src/app-resources/common/LICENSE-ASM.txt | 28 +++ .../common/LICENSE-Apache-2.0.txt | 202 ++++++++++++++++++ .../common/THIRD-PARTY-NOTICES.txt | 4 + .../plugins/DesktopPluginManager.kt | 53 +++-- .../cloudstream4/plugins/PluginLoader.kt | 8 +- .../cloudstream4/plugins/RepositoryClient.kt | 13 +- .../cloudstream4/plugins/PluginTest.kt | 32 +++ 9 files changed, 325 insertions(+), 27 deletions(-) create mode 100644 desktopApp/src/app-resources/common/LICENSE-ASM.txt create mode 100644 desktopApp/src/app-resources/common/LICENSE-Apache-2.0.txt diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt index 786cf294cc5..cfb7d541440 100644 --- a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/AndroidPluginClassLoader.kt @@ -18,6 +18,8 @@ import java.util.jar.JarFile class AndroidPluginClassLoader( private val jar: Path, parent: ClassLoader, + /** Stubs and redirects what desktop lacks, for Android plugins. Off for desktop jars, which fail plainly */ + private val stubMissing: Boolean = true, ) : URLClassLoader(arrayOf(jar.toUri().toURL()), parent) { /** Read from the jar the first time a class loads */ private val model by lazy { StubModel.scan(jar) } @@ -34,16 +36,16 @@ class AndroidPluginClassLoader( val stubbedClasses: Set get() = synchronized(stubbed) { stubbed.toSet() } override fun findClass(name: String): Class<*> { - missingMembers.helperClass(name)?.let { return define(name, it) } + if (stubMissing) missingMembers.helperClass(name)?.let { return define(name, it) } val internalName = name.replace('.', '/') val entry = synchronized(jarFile) { jarFile.getJarEntry("$internalName.class") } if (entry != null) { val bytes = synchronized(jarFile) { jarFile.getInputStream(entry).use { it.readBytes() } } - return define(name, missingMembers.rewrite(name, bytes)) + return define(name, if (stubMissing) missingMembers.rewrite(name, bytes) else bytes) } - if (!StubHierarchy.isStubbed(internalName)) throw ClassNotFoundException(name) + if (!stubMissing || !StubHierarchy.isStubbed(internalName)) throw ClassNotFoundException(name) val bytes = generator.generate(internalName) synchronized(stubbed) { stubbed += name } return define(name, bytes) diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt index 1d055578bc4..27e83b5ee07 100644 --- a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt @@ -14,7 +14,6 @@ import com.googlecode.d2j.visitors.DexFileVisitor import org.objectweb.asm.MethodVisitor import org.objectweb.asm.Opcodes import java.lang.reflect.Modifier -import java.net.URI import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path @@ -137,7 +136,8 @@ object DexConverter { val manifest = ZipFile(cs3.toFile()).use { zip -> zip.getEntry(MANIFEST)?.let { entry -> zip.getInputStream(entry).use { it.readBytes() } } } ?: return - FileSystems.newFileSystem(URI.create("jar:" + jar.toUri()), emptyMap()).use { fs -> + // Opened by path: a "jar:" uri breaks on a folder name with "!" in it + FileSystems.newFileSystem(jar).use { fs -> Files.write(fs.getPath(MANIFEST), manifest) } } diff --git a/desktopApp/src/app-resources/common/LICENSE-ASM.txt b/desktopApp/src/app-resources/common/LICENSE-ASM.txt new file mode 100644 index 00000000000..4d191851af4 --- /dev/null +++ b/desktopApp/src/app-resources/common/LICENSE-ASM.txt @@ -0,0 +1,28 @@ + + ASM: a very small and fast Java bytecode manipulation framework + Copyright (c) 2000-2011 INRIA, France Telecom + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + THE POSSIBILITY OF SUCH DAMAGE. diff --git a/desktopApp/src/app-resources/common/LICENSE-Apache-2.0.txt b/desktopApp/src/app-resources/common/LICENSE-Apache-2.0.txt new file mode 100644 index 00000000000..d6456956733 --- /dev/null +++ b/desktopApp/src/app-resources/common/LICENSE-Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt index abc81c16d35..02b8b8de92a 100644 --- a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt +++ b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt @@ -36,3 +36,7 @@ libraries, which are included as they are: (dx), Apache License 2.0 ASM 9.10.1 BSD 3-Clause License, https://asm.ow2.io Gson 2.14.0 Apache License 2.0, https://github.com/google/gson + +The Apache License 2.0 is in LICENSE-Apache-2.0.txt next to this file, and +ASM's licence, which also covers the copy of ASM inside dex2jar, is in +LICENSE-ASM.txt. diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt index 3d217c0bd1d..96ff72954ad 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -85,8 +85,17 @@ class DesktopPluginManager( _loadedNames.value = loaded.keys.toSet() } - private fun jarFile(internalName: String): Path = dir.resolve("$internalName.jar") - private fun androidFile(internalName: String): Path = dir.resolve("android").resolve("$internalName.cs3") + private fun jarFile(internalName: String): Path = inside(dir, "$internalName.jar") + private fun androidFile(internalName: String): Path = inside(dir.resolve("android"), "$internalName.cs3") + + /** A plugin's file, refusing a name that would land outside its folder */ + private fun inside(folder: Path, name: String): Path { + val file = folder.resolve(name).normalize() + require(RepositoryClient.isSafeInternalName(name.substringBeforeLast('.')) && file.parent == folder.normalize()) { + "\"$name\" is not a safe plugin file name" + } + return file + } init { // Android plugins look themselves up in the Android app's plugin manager @@ -121,18 +130,29 @@ class DesktopPluginManager( lock.withLock { withContext(Dispatchers.IO) { dir.createDirectories() - loaded.remove(plugin.internalName)?.unload() - publishLoaded() val target = jarFile(plugin.internalName) - if (android) { - val cs3 = androidFile(plugin.internalName) - cs3.parent.createDirectories() - writeAtomically(cs3, bytes) - convert(plugin.internalName, cs3, target) - } else { - writeAtomically(target, bytes) - androidFile(plugin.internalName).deleteIfExists() + val cs3 = androidFile(plugin.internalName) + // The new version is made ready beside the old one, which keeps working if that fails + val newJar = target.resolveSibling("${target.fileName}.new") + val newCs3 = cs3.resolveSibling("${cs3.fileName}.new") + try { + if (android) { + cs3.parent.createDirectories() + newCs3.writeBytes(bytes) + convert(plugin.internalName, newCs3, newJar) + } else { + newJar.writeBytes(bytes) + } + } catch (t: Throwable) { + newJar.deleteIfExists() + newCs3.deleteIfExists() + throw t } + // Windows cannot replace a jar that is still loaded + loaded.remove(plugin.internalName)?.unload() + publishLoaded() + Files.move(newJar, target, REPLACE_EXISTING, ATOMIC_MOVE) + if (android) Files.move(newCs3, cs3, REPLACE_EXISTING, ATOMIC_MOVE) else cs3.deleteIfExists() val entry = InstalledPlugin( plugin.internalName, plugin.name, plugin.version, repositoryUrl, @@ -181,12 +201,6 @@ class DesktopPluginManager( } } - private fun writeAtomically(target: Path, bytes: ByteArray) { - val tmp = target.resolveSibling("${target.fileName}.tmp") - tmp.writeBytes(bytes) - Files.move(tmp, target, REPLACE_EXISTING, ATOMIC_MOVE) - } - private fun convert(internalName: String, cs3: Path, target: Path) { val start = System.nanoTime() val result = try { @@ -216,7 +230,8 @@ class DesktopPluginManager( val file = jarFile(internalName) return try { if (!file.exists()) throw PluginDownloadException("$file is missing, install the plugin again") - loaded[internalName] = PluginLoader.load(file) + val android = _installed.value.firstOrNull { it.internalName == internalName }?.isAndroid ?: true + loaded[internalName] = PluginLoader.load(file, android = android) publishLoaded() _errors.update { it - internalName } println("INFO DesktopPluginManager: Loaded $internalName") diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt index 15000d01301..bec0ed22638 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/PluginLoader.kt @@ -52,8 +52,12 @@ class LoadedPlugin internal constructor( object PluginLoader { private const val ANNOTATION_DESCRIPTOR = "Lcom/lagradost/cloudstream3/plugins/CloudstreamPlugin;" - fun load(file: Path, parent: ClassLoader = PluginLoader::class.java.classLoader): LoadedPlugin { - val classLoader = AndroidPluginClassLoader(file, parent) + /** + * [android] is for a plugin converted from Android: what it uses that desktop lacks is stubbed. + * A desktop jar is built against the desktop library, so a missing class there is a real error + */ + fun load(file: Path, parent: ClassLoader = PluginLoader::class.java.classLoader, android: Boolean = true): LoadedPlugin { + val classLoader = AndroidPluginClassLoader(file, parent, stubMissing = android) try { val className = findPluginClassName(file) ?: throw IllegalArgumentException("No class annotated with @CloudstreamPlugin in $file") diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt index 665847f8eb4..2509c275f73 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/RepositoryClient.kt @@ -189,11 +189,22 @@ class RepositoryClient( private val DEEP_LINK_REGEX = """^(cloudstreamrepo://|https://cs\.repo/\??)""".toRegex() private val SHORT_CODE_REGEX = "^[a-zA-Z0-9!_-]+$".toRegex() + private val INTERNAL_NAME_REGEX = "^[A-Za-z0-9._-]{1,100}$".toRegex() + + /** Letters, digits, dots, dashes and underscores, and not "." or ".." */ + fun isSafeInternalName(name: String) = INTERNAL_NAME_REGEX.matches(name) && name.trim('.').isNotEmpty() + internal fun parsePluginList(text: String): List = Json.parseToJsonElement(text).jsonArray.mapNotNull { element -> val obj = element as? JsonObject ?: return@mapNotNull null + val internalName = obj.string("internalName") ?: return@mapNotNull null + // It names the plugin's files: a repository must not be able to point them elsewhere + if (!isSafeInternalName(internalName)) { + println("WARNING RepositoryClient: Skipped a plugin named \"$internalName\", not a safe file name") + return@mapNotNull null + } RepoPlugin( - internalName = obj.string("internalName") ?: return@mapNotNull null, + internalName = internalName, name = obj.string("name") ?: obj.string("internalName")!!, version = obj.primitive("version")?.intOrNull ?: 0, status = obj.primitive("status")?.intOrNull ?: 1, diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt index 65200f298b1..ba97dba3156 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.runBlocking import java.io.ByteArrayOutputStream import java.net.InetSocketAddress import java.nio.file.Files +import kotlin.io.path.readBytes import java.nio.file.Path import java.security.MessageDigest import java.util.jar.JarEntry @@ -103,6 +104,15 @@ class PluginTest { assertEquals(10L, plugins[0].jarFileSize) } + @Test + fun aPluginNameThatIsNotASafeFileNameIsSkipped() { + val names = listOf("../../evil", "C:/Users/Public/evil", "..", "a b", "ok.Name_1-2") + val plugins = RepositoryClient.parsePluginList( + names.joinToString(",", "[", "]") { """{"internalName":"$it","name":"x","version":1,"jarUrl":"https://h/x.jar"}""" } + ) + assertEquals(listOf("ok.Name_1-2"), plugins.map { it.internalName }) + } + /** Serves repo.json, plugins.json and the jar from a local server, like a GitHub repository */ private fun serveRepository(jar: ByteArray, jarHash: String = sha256(jar)): String { val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).also { this.server = it } @@ -112,6 +122,7 @@ class PluginTest { "/plugins.json" to """[{"internalName":"TestPlugin","name":"Test plugin","version":2,"status":1, "jarUrl":"$base/TestPlugin.jar","jarHash":"$jarHash","jarFileSize":${jar.size}}]""".toByteArray(), "/TestPlugin.jar" to jar, + "/Broken.cs3" to "not an Android extension".toByteArray(), ) server.createContext("/") { exchange -> val body = files[exchange.requestURI.path] @@ -153,6 +164,27 @@ class PluginTest { assertTrue(APIHolder.apis.none { it.name == "Desktop Test Provider" }) } + @Test + fun anUpdateThatFailsToConvertKeepsTheOldVersionRunning() = runBlocking { + val repoUrl = serveRepository(pluginJar(*pluginClasses)) + val client = RepositoryClient() + val plugin = client.fetchPlugins(client.fetchRepository(repoUrl)).single() + val manager = DesktopPluginManager(dir.resolve("plugins"), client) + manager.install(repoUrl, plugin) + val jar = dir.resolve("plugins/TestPlugin.jar") + val before = jar.readBytes() + + // Version 3 is only an Android build, and not a readable one + val broken = plugin.copy(version = 3, jarUrl = null, jarHash = null, jarFileSize = null, androidUrl = repoUrl.replace("repo.json", "Broken.cs3")) + assertFailsWith { manager.install(repoUrl, broken) } + assertNotNull(manager.loadedPlugin("TestPlugin"), "the old version is still loaded") + assertNotNull(APIHolder.getApiFromNameNull("Desktop Test Provider")) + assertEquals(2, manager.installed.value.single().version) + assertTrue(before.contentEquals(jar.readBytes())) + assertTrue(Files.list(dir.resolve("plugins")).use { files -> files.noneMatch { it.fileName.toString().endsWith(".new") } }) + manager.uninstall("TestPlugin") + } + @Test fun downloadWithTheWrongHashIsRefused() = runBlocking { val repoUrl = serveRepository(pluginJar(*pluginClasses), jarHash = "sha256-" + "0".repeat(64)) From e17f53d119bff7f74a7e1d8a27f47f32b38a1d8b Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 15:51:15 +0530 Subject: [PATCH 24/64] Desktop: hard limits for the extension run, and an AppCompatActivity - AndroidExtensionsTest runs each search and plugin load on its own thread and gives up after 30 s / 60 s, so a search stuck in blocking code can no longer hold the whole run. The full run over 461 extensions now finishes in about 21 minutes. - The desktop activity is an androidx AppCompatActivity, with real stand-ins for its parent classes: 15 extensions cast it to that when they load. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../androidx/activity/ComponentActivity.kt | 4 ++ .../appcompat/app/AppCompatActivity.kt | 6 +++ .../androidx/core/app/ComponentActivity.kt | 11 +++++ .../androidx/fragment/app/FragmentActivity.kt | 6 +++ .../cloudstream4/android/DesktopAndroid.kt | 3 +- .../plugins/AndroidExtensionsTest.kt | 43 +++++++++++++++++-- 6 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 androidCompat/src/jvmMain/kotlin/androidx/activity/ComponentActivity.kt create mode 100644 androidCompat/src/jvmMain/kotlin/androidx/appcompat/app/AppCompatActivity.kt create mode 100644 androidCompat/src/jvmMain/kotlin/androidx/core/app/ComponentActivity.kt create mode 100644 androidCompat/src/jvmMain/kotlin/androidx/fragment/app/FragmentActivity.kt diff --git a/androidCompat/src/jvmMain/kotlin/androidx/activity/ComponentActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/activity/ComponentActivity.kt new file mode 100644 index 00000000000..f1f10807b2f --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/activity/ComponentActivity.kt @@ -0,0 +1,4 @@ +package androidx.activity + +/** See androidx.core.app.ComponentActivity */ +open class ComponentActivity : androidx.core.app.ComponentActivity() diff --git a/androidCompat/src/jvmMain/kotlin/androidx/appcompat/app/AppCompatActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/appcompat/app/AppCompatActivity.kt new file mode 100644 index 00000000000..4940bbe1e7f --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/appcompat/app/AppCompatActivity.kt @@ -0,0 +1,6 @@ +package androidx.appcompat.app + +import androidx.fragment.app.FragmentActivity + +/** The desktop app's activity, as Android extensions expect it. See androidx.core.app.ComponentActivity */ +open class AppCompatActivity : FragmentActivity() diff --git a/androidCompat/src/jvmMain/kotlin/androidx/core/app/ComponentActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/core/app/ComponentActivity.kt new file mode 100644 index 00000000000..e6c143bc2d9 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/core/app/ComponentActivity.kt @@ -0,0 +1,11 @@ +package androidx.core.app + +import android.app.Activity + +/** + * Real desktop classes for the activity chain Android extensions cast the app's activity to, often + * `context as AppCompatActivity` in their load. Stubs generated inside the extension would be other + * classes than the app's activity, so the cast failed. Members desktop lacks are redirected by + * MissingMembers like on any desktop stand-in. + */ +open class ComponentActivity : Activity() diff --git a/androidCompat/src/jvmMain/kotlin/androidx/fragment/app/FragmentActivity.kt b/androidCompat/src/jvmMain/kotlin/androidx/fragment/app/FragmentActivity.kt new file mode 100644 index 00000000000..d45119236b5 --- /dev/null +++ b/androidCompat/src/jvmMain/kotlin/androidx/fragment/app/FragmentActivity.kt @@ -0,0 +1,6 @@ +package androidx.fragment.app + +import androidx.activity.ComponentActivity + +/** See androidx.core.app.ComponentActivity */ +open class FragmentActivity : ComponentActivity() diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt index 1a092aca9c3..7c33f84d216 100644 --- a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DesktopAndroid.kt @@ -38,7 +38,8 @@ object DesktopAndroid { preferences.computeIfAbsent(name) { DesktopSharedPreferences(prefsDir.resolve("${safeFileName(it)}.json")) } val application: Application by lazy { Application() } - val activity: Activity by lazy { Activity() } + /** An AppCompatActivity, which extensions often cast it to */ + val activity: Activity by lazy { androidx.appcompat.app.AppCompatActivity() } /** Shows an extension's toast in the app. Without one, toasts are only logged */ @Volatile diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt index 00bee7cfbc5..937d6dee94f 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt @@ -13,12 +13,15 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import kotlin.io.path.createDirectories import kotlin.io.path.listDirectoryEntries import kotlin.io.path.name @@ -38,6 +41,8 @@ class AndroidExtensionsTest { NO_RESULTS, /** The site or network failed, not desktop */ SITE_ERROR, + /** Every search ran past the hard limit */ + TIMED_OUT, /** A class or member desktop lacks */ MISSING_ON_DESKTOP, /** Loaded, but registered no provider to search with, such as extractor-only plugins */ @@ -56,12 +61,35 @@ class AndroidExtensionsTest { var stubbed = setOf() } + /** + * Runs [block] on its own thread and gives up on it after [limitMs]. Some extensions block in + * code a coroutine timeout cannot stop (a socket read without a timeout, a busy loop), which + * once held a full run for over 90 minutes; an abandoned thread is a daemon and is left behind. + */ + private fun withHardLimit(limitMs: Long, what: String, block: suspend () -> T): T { + val future = hardLimitThreads.submit { runBlocking { block() } } + try { + return future.get(limitMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + future.cancel(true) + throw HardLimitException("$what took over ${limitMs / 1000} s") + } catch (e: ExecutionException) { + throw e.cause ?: e + } + } + + private class HardLimitException(message: String) : Exception(message) + + private val hardLimitThreads = Executors.newCachedThreadPool { task -> + Thread(task, "extension-test").apply { isDaemon = true } + } + private fun Throwable.isMissingOnDesktop(): Boolean = generateSequence(this) { it.cause }.any { it is LinkageError || it is AbstractMethodError || it is ClassCastException } @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) @Test - fun androidExtensionsWorkOnDesktop() = runBlocking { + fun androidExtensionsWorkOnDesktop() = runBlocking { val folder = System.getenv("CLOUDSTREAM_ANDROID_EXTENSIONS") ?: return@runBlocking println("Skipped, set CLOUDSTREAM_ANDROID_EXTENSIONS") // Common words in a few languages and a popular title, since sites search in their own language val queries = System.getenv("CLOUDSTREAM_SEARCH_QUERY")?.split(',') ?: listOf("the", "a", "naruto", "love") @@ -87,7 +115,7 @@ class AndroidExtensionsTest { return@map report to null } val loaded = try { - PluginLoader.load(jar) + withHardLimit(LOAD_LIMIT_MS, "Loading") { PluginLoader.load(jar) } } catch (t: Throwable) { report.outcome = if (t.isMissingOnDesktop()) Outcome.MISSING_ON_DESKTOP else Outcome.LOAD_FAILED report.detail = "load: $t" @@ -111,7 +139,7 @@ class AndroidExtensionsTest { for (query in queries) { val items = searches.withPermit { try { - withTimeout(30_000) { provider.search(query, 1)?.items.orEmpty() } + withHardLimit(SEARCH_LIMIT_MS, "Searching") { provider.search(query, 1)?.items.orEmpty() } } catch (t: Throwable) { errors += t emptyList() @@ -124,6 +152,7 @@ class AndroidExtensionsTest { report.outcome = when { report.results > 0 -> Outcome.WORKS errors.any { it.isMissingOnDesktop() } -> Outcome.MISSING_ON_DESKTOP + errors.isNotEmpty() && errors.all { it is HardLimitException } -> Outcome.TIMED_OUT errors.isNotEmpty() -> Outcome.SITE_ERROR else -> Outcome.NO_RESULTS } @@ -159,5 +188,11 @@ class AndroidExtensionsTest { println("Report: ${out.resolve("report.json").toAbsolutePath()}") work.toFile().deleteRecursively() APIHolder.allProviders.clear() + hardLimitThreads.shutdownNow() + } + + companion object { + private const val SEARCH_LIMIT_MS = 30_000L + private const val LOAD_LIMIT_MS = 60_000L } } From a3c05c9b546e081c2c125a52cc57408b906ad3d2 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 17:03:40 +0530 Subject: [PATCH 25/64] Desktop: fetch libmpv and 7zr from the fork's mirror release shinchiro keeps only its last 30 builds, and 7-zip.org replaces 7zr.exe with each new version, so both pinned downloads would stop resolving. Co-Authored-By: Claude Opus 5.5 (1M context) --- desktopApp/build.gradle.kts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 031897eea48..2a76b44961a 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -75,11 +75,12 @@ abstract class PrepareLibmpv : DefaultTask() { } val prepareLibmpv by tasks.registering(PrepareLibmpv::class) { - // shinchiro keeps builds for a few months only: before this one is removed, update to a newer build or mirror it - archiveUrl = "https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20261002/mpv-dev-x86_64-20261002-git-3186d369f9.7z" + // shinchiro's 20261002 build, mirrored on the fork because shinchiro keeps only its last 30 builds + archiveUrl = "https://github.com/harsh-pockt/cloudstream/releases/download/libmpv-20261002/mpv-dev-x86_64-20261002-git-3186d369f9.7z" archiveSha256 = "d873450cc1a7f881a8a10c33936d9555ad18a3809d827b9dbea55ba55caebcf9" - // The archive uses the BCJ2 filter, which only 7-Zip itself unpacks. 7zr.exe is its small standalone version - sevenZipUrl = "https://www.7-zip.org/a/7zr.exe" + // The archive uses the BCJ2 filter, which only 7-Zip itself unpacks. 7zr.exe is its small standalone version, + // mirrored too: 7-zip.org replaces it with each new version + sevenZipUrl = "https://github.com/harsh-pockt/cloudstream/releases/download/libmpv-20261002/7zr.exe" sevenZipSha256 = "ad4c82fadcbdf93c03b4fc440f300509c7d60c5c2f4d183e35d9d70d6957037d" downloadDir = layout.buildDirectory.dir("libmpv-download") // In the windows folder of the app resources, so it is only bundled on Windows From 7f424f64133287ce4ff71542b9c110261bbe42b4 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 18:03:17 +0530 Subject: [PATCH 26/64] Desktop: convert memory-hungry extensions within the app's 1 GB, and report plugin file urls like Android - DexConverter: when a conversion runs out of memory, it is tried again without the heaviest method (up to 3 times), instead of the install failing. AniSnatch's loadLinks needs over 768 MB, though methods as big with as many try blocks need under 384 MB, so the cost cannot be told beforehand. - PluginData.url is now the file the plugin was downloaded from, like Android. Five Vietnamese extensions only register when it points to their own repository. - AndroidExtensionsTest: records each conversion's peak heap, takes -PtestHeap to run with the app's memory, and reads an optional sources.json so extensions see where they came from. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/android/DexConverter.kt | 47 +++++++++++++++---- shared/build.gradle.kts | 3 +- .../plugins/DesktopPluginManager.kt | 9 +++- .../plugins/AndroidExtensionsTest.kt | 35 ++++++++++++++ .../cloudstream4/plugins/PluginTest.kt | 3 ++ 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt index 27e83b5ee07..fe7da2fc325 100644 --- a/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt +++ b/androidCompat/src/jvmMain/kotlin/com/lagradost/cloudstream4/android/DexConverter.kt @@ -48,7 +48,35 @@ object DexConverter { private const val MAX_INSTRUCTIONS = 12_000 private const val MAX_COST = 3_000_000L + /** + * How many times a conversion that ran out of memory is tried again, each time without the + * heaviest method left. How much memory a method needs cannot be told from its size: AniSnatch's + * loadLinks needs over 768 MB, while StreamCenter's loadTmdbMedia, as big and with as many try + * blocks, needs under 384 MB. + */ + private const val OUT_OF_MEMORY_RETRIES = 3 + fun convert(cs3: Path, jar: Path): ConversionResult { + val dex = DexFileNode() + MultiDexFileReader.open(Files.readAllBytes(cs3)).accept(dex, READ_FLAGS) + var replaced = replaceHugeMethods(dex, cs3) + var retries = 0 + while (true) { + try { + return ConversionResult(replaced + translate(dex, cs3, jar)) + } catch (e: OutOfMemoryError) { + // What dex2jar built is unreachable now, so the memory is back + if (retries++ >= OUT_OF_MEMORY_RETRIES) throw e + val heaviest = methods(dex).maxByOrNull { cost(it.codeNode) } ?: throw e + println("WARNING DexConverter: Ran out of memory converting $cs3, trying again without ${heaviest.method}") + heaviest.codeNode = throwingCode(heaviest) + replaced++ + } + } + } + + /** Writes the jar, returns how many methods could not be converted */ + private fun translate(dex: DexFileNode, cs3: Path, jar: Path): Int { val failed = AtomicInteger() val handler = object : DexExceptionHandler { override fun handleFileException(e: Exception) { @@ -67,10 +95,6 @@ object DexConverter { } } - val dex = DexFileNode() - MultiDexFileReader.open(Files.readAllBytes(cs3)).accept(dex, READ_FLAGS) - failed.addAndGet(replaceHugeMethods(dex, cs3)) - val tmp = jar.resolveSibling("${jar.fileName}.tmp") tmp.deleteIfExists() try { @@ -92,17 +116,20 @@ object DexConverter { } finally { tmp.deleteIfExists() } - return ConversionResult(failed.get()) + return failed.get() } + private fun methods(dex: DexFileNode) = dex.clzs.orEmpty().asSequence().flatMap { it.methods.orEmpty() }.filter { it.codeNode != null } + + /** Roughly how much work dex2jar has with a method */ + private fun cost(code: DexCodeNode): Long = code.stmts.size.toLong() * (code.totalRegister + (code.tryStmts?.size ?: 0)) + /** Gives methods too big to convert a body that throws. Returns how many there were */ private fun replaceHugeMethods(dex: DexFileNode, cs3: Path): Int { var replaced = 0 - for (cls in dex.clzs.orEmpty()) for (method in cls.methods.orEmpty()) { - val code = method.codeNode ?: continue - val instructions = code.stmts.size - val cost = instructions.toLong() * (code.totalRegister + (code.tryStmts?.size ?: 0)) - if (instructions <= MAX_INSTRUCTIONS && cost <= MAX_COST) continue + for (method in methods(dex)) { + val instructions = method.codeNode.stmts.size + if (instructions <= MAX_INSTRUCTIONS && cost(method.codeNode) <= MAX_COST) continue println("WARNING DexConverter: ${method.method} in $cs3 is too big to convert ($instructions instructions)") method.codeNode = throwingCode(method) replaced++ diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 0fb40cb3cfd..3c33bc564f4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -78,7 +78,8 @@ compose.resources { } // The Android extensions test converts and loads hundreds of extensions in one run tasks.withType().configureEach { - maxHeapSize = "3g" + // -PtestHeap=1g runs them with the memory the app has + maxHeapSize = providers.gradleProperty("testHeap").getOrElse("3g") // Tests keep their files, such as the HTTP cache, away from the real app's folders systemProperty("cloudstream.home", layout.buildDirectory.dir("test-home").get().asFile.absolutePath) // Tests that start the installed Edge or Chrome, off by default: gradlew :shared:jvmTest -PbrowserTests diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt index 96ff72954ad..1257a3af6dc 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -48,6 +48,8 @@ data class InstalledPlugin( val convertedWith: Int = 0, /** From the repository, with %size% where the size goes like Android */ val iconUrl: String? = null, + /** The file it was downloaded from. Android extensions check it, see PluginData.url */ + val fileUrl: String? = null, ) /** A newer version of an installed plugin in its repository */ @@ -100,7 +102,8 @@ class DesktopPluginManager( init { // Android plugins look themselves up in the Android app's plugin manager PluginManager.installed = { - _installed.value.map { PluginData(it.internalName, it.repositoryUrl, true, jarFile(it.internalName).toString(), it.version) } + // Like Android, the url is the file's: some extensions only register when it is from their own repository + _installed.value.map { PluginData(it.internalName, it.fileUrl ?: it.repositoryUrl, true, jarFile(it.internalName).toString(), it.version) } } } @@ -157,7 +160,7 @@ class DesktopPluginManager( val entry = InstalledPlugin( plugin.internalName, plugin.name, plugin.version, repositoryUrl, isAndroid = android, convertedWith = if (android) CONVERTER_VERSION else 0, - iconUrl = plugin.iconUrl, + iconUrl = plugin.iconUrl, fileUrl = if (android) plugin.androidUrl else plugin.jarUrl, ) writeIndex(_installed.value.filterNot { it.internalName == plugin.internalName } + entry) loadLocked(plugin.internalName)?.let { throw it } @@ -256,6 +259,7 @@ class DesktopPluginManager( isAndroid = obj["isAndroid"]?.jsonPrimitive?.boolean ?: false, convertedWith = obj["convertedWith"]?.jsonPrimitive?.int ?: 0, iconUrl = obj["iconUrl"]?.jsonPrimitive?.takeIf { it.isString }?.content, + fileUrl = obj["fileUrl"]?.jsonPrimitive?.takeIf { it.isString }?.content, ) } }.getOrElse { @@ -275,6 +279,7 @@ class DesktopPluginManager( "isAndroid" to JsonPrimitive(it.isAndroid), "convertedWith" to JsonPrimitive(it.convertedWith), "iconUrl" to JsonPrimitive(it.iconUrl), + "fileUrl" to JsonPrimitive(it.fileUrl), ) ) }) diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt index 937d6dee94f..bcd6eac660b 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/AndroidExtensionsTest.kt @@ -1,6 +1,8 @@ package com.lagradost.cloudstream4.plugins import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.plugins.PluginData +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream4.android.DesktopAndroid import com.lagradost.cloudstream4.android.DexConverter import kotlinx.coroutines.DelicateCoroutinesApi @@ -13,9 +15,14 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.lang.management.ManagementFactory +import java.lang.management.MemoryType import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.ExecutionException @@ -23,8 +30,10 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import kotlin.io.path.createDirectories +import kotlin.io.path.exists import kotlin.io.path.listDirectoryEntries import kotlin.io.path.name +import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.test.Test @@ -55,6 +64,8 @@ class AndroidExtensionsTest { var outcome = Outcome.LOAD_FAILED var detail = "" var convertMillis = 0L + /** The most heap in use while it converted, with what was in use before */ + var convertPeakMb = 0L var failedMethods = 0 var providers = listOf() var results = 0 @@ -78,6 +89,17 @@ class AndroidExtensionsTest { } } + /** Collects first, so the peak starts from what is really in use */ + private fun resetHeapPeak() { + System.gc() + heapPools().forEach { it.resetPeakUsage() } + } + + /** Pools peak at different moments, so their sum is at most this much higher than the real peak */ + private fun heapPeakMb(): Long = heapPools().sumOf { it.peakUsage.used } / (1024 * 1024) + + private fun heapPools() = ManagementFactory.getMemoryPoolMXBeans().filter { it.type == MemoryType.HEAP } + private class HardLimitException(message: String) : Exception(message) private val hardLimitThreads = Executors.newCachedThreadPool { task -> @@ -100,15 +122,25 @@ class AndroidExtensionsTest { val out = Path.of("build", "android-extensions").createDirectories() val files = Path.of(folder).listDirectoryEntries("*.cs3").sortedBy { it.name } println("Testing ${files.size} extensions, searching for $queries") + // Where each came from, as {"": ""}. Some extensions only + // register when the app says they were installed from their own repository + val sources = Path.of(folder).resolve("sources.json").takeIf { it.exists() }?.let { file -> + Json.parseToJsonElement(file.readText()).jsonObject.mapValues { it.value.jsonPrimitive.content } + }.orEmpty() + val installed = mutableListOf() + PluginManager.installed = { installed.toList() } // Convert and load one at a time, plugins register into one global list val reports = files.map { cs3 -> val report = Report(cs3.name.removeSuffix(".cs3")) val jar = work.resolve(cs3.name.removeSuffix(".cs3") + ".jar") + installed += PluginData(report.name, sources[report.name], true, jar.toString(), 1) try { val start = System.nanoTime() + resetHeapPeak() report.failedMethods = DexConverter.convert(cs3, jar).failedMethods report.convertMillis = (System.nanoTime() - start) / 1_000_000 + report.convertPeakMb = heapPeakMb() } catch (t: Throwable) { report.outcome = Outcome.CONVERSION_FAILED report.detail = t.toString() @@ -174,6 +206,7 @@ class AndroidExtensionsTest { "outcome" to JsonPrimitive(it.outcome.name), "detail" to JsonPrimitive(it.detail), "convertMillis" to JsonPrimitive(it.convertMillis), + "convertPeakMb" to JsonPrimitive(it.convertPeakMb), "failedMethods" to JsonPrimitive(it.failedMethods), "providers" to JsonArray(it.providers.map(::JsonPrimitive)), "results" to JsonPrimitive(it.results), @@ -185,9 +218,11 @@ class AndroidExtensionsTest { val counts = all.groupingBy { it.outcome }.eachCount() println("Results for ${all.size} extensions:") Outcome.entries.forEach { println(" ${it.name}: ${counts[it] ?: 0}") } + all.maxByOrNull { it.convertPeakMb }?.let { println("Most heap to convert: ${it.convertPeakMb} MB, ${it.name}") } println("Report: ${out.resolve("report.json").toAbsolutePath()}") work.toFile().deleteRecursively() APIHolder.allProviders.clear() + PluginManager.installed = { emptyList() } hardLimitThreads.shutdownNow() } diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt index ba97dba3156..a9e76086e9b 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream4.plugins import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.plugins.PluginManager import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.runBlocking import java.io.ByteArrayOutputStream @@ -155,6 +156,8 @@ class PluginTest { manager.loadedPlugin("TestPlugin")!!.unload() val restarted = DesktopPluginManager(dir.resolve("plugins"), client) assertEquals(2, restarted.installed.value.single().version) + // Like Android, extensions see the file they came from, kept across restarts + assertEquals(listOf("${repoUrl.removeSuffix("repo.json")}TestPlugin.jar"), PluginManager.getPluginsOnline().map { it.url }) restarted.loadAll() assertNotNull(restarted.loadedPlugin("TestPlugin")) From d395ac78843e16d6067550cea3a4e89c35c8b70d Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 18:47:58 +0530 Subject: [PATCH 27/64] Desktop: run Edge headless, so playing a video never opens a browser window Extensions' WebView lookups and Cloudflare checks ran in a visible Edge moved off screen, which still showed in the taskbar, and came on screen for Cloudflare's tick-the-box check. Edge now runs headless: - No window at all. Its user agent is sent without "Headless", which sites refuse. - A check that wants a box ticked cannot be passed: the user is told once per site why its links do not come, and the lookup fails like a site that is down. Checks that pass by themselves still pass. - Headless Edge starts its browser as a separate process and the started one exits, so startup waits on the DevTools port, and the browser's own process (from SystemInfo.getProcessInfo) is the one closed on exit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../kotlin/com/lagradost/cloudstream4/Main.kt | 5 +- .../cloudstream4/WindowsFullscreen.kt | 8 -- .../cloudstream4/browser/SystemBrowser.kt | 98 +++++++++---------- .../cloudstream4/browser/SystemBrowserTest.kt | 4 + 4 files changed, 54 insertions(+), 61 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index bb99d0fdb5e..34a014e7549 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -63,9 +63,8 @@ fun main() { // Before the extensions load: they make their requests through it @OptIn(DelicateCoroutinesApi::class) DesktopHttp.start(GlobalScope) - // Edge or Chrome stands in for Android's WebView, for extensions behind Cloudflare and the like - // The app is in front when an extension needs the browser, and lets it come forward - SystemBrowser.install()?.onShow = WindowsForeground::allow + // Edge or Chrome, without a window, stands in for Android's WebView, for extensions behind Cloudflare and the like + SystemBrowser.install() // Plugins load in the background so a slow or broken plugin never delays the window @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt index d32a557ae2f..5a4668504c1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt @@ -18,7 +18,6 @@ private interface User32 : StdCallLibrary { fun ShowWindow(hwnd: Pointer, cmd: Int): Boolean fun IsZoomed(hwnd: Pointer): Boolean fun SetForegroundWindow(hwnd: Pointer): Boolean - fun AllowSetForegroundWindow(processId: Int): Boolean } private val user32: User32? by lazy { @@ -132,10 +131,3 @@ object WindowsHide { user32.SetForegroundWindow(hwnd) } } - -/** Lets another program's window come in front of the app's, which Windows otherwise refuses it */ -object WindowsForeground { - fun allow(processId: Long) { - user32?.AllowSetForegroundWindow(processId.toInt()) - } -} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt index f05baa867aa..e8f9e1e3531 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt @@ -39,22 +39,23 @@ import java.util.concurrent.atomic.AtomicInteger /** * The browser behind extensions' WebViewResolver and CloudflareKiller on desktop: Microsoft Edge, * which comes with Windows, or Chrome, run with its own profile in the app's cache folder and driven - * through the DevTools protocol. Its window stays off screen, unless a page waits on Cloudflare's - * check for a few seconds: then it shows, so the user can tick the box. The browser closes after a - * few idle minutes, and when the app exits. + * through the DevTools protocol. It runs headless, without a window: Cloudflare checks that pass by + * themselves are passed, one that wants a box ticked fails like a site that is down. The browser + * closes after a few idle minutes, and when the app exits. */ class SystemBrowser( private val executable: File, private val profile: File = AppDirs.cache.resolve("browser").toFile(), - /** Where the window opens: off screen, unless someone needs to watch it */ - private val hidden: Boolean = true, + /** Without a window. False shows the browser, to watch what a page does */ + private val headless: Boolean = true, ) : DesktopWebView { private val json = jacksonObjectMapper() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val startLock = Mutex() private val http = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build() - private class Connection(val process: Process, val socket: WebSocket) + /** [browser] is the browser's main process, null when it could not be told */ + private class Connection(val browser: ProcessHandle?, val socket: WebSocket) @Volatile private var connection: Connection? = null private val nextId = AtomicInteger(1) @@ -63,13 +64,11 @@ class SystemBrowser( private val sessions = ConcurrentHashMap>() private val loads = AtomicInteger(0) private var idleJob: Job? = null + private val reportedHosts = ConcurrentHashMap.newKeySet() @Volatile override var userAgent: String? = null private set - /** Called with the browser's process id before its window comes forward, which Windows must allow */ - var onShow: ((Long) -> Unit)? = null - override suspend fun load( request: Request, userAgent: String?, @@ -116,7 +115,7 @@ class SystemBrowser( events: Channel, ) { call("Network.enable", session = session) - if (userAgent != null) call("Network.setUserAgentOverride", mapOf("userAgent" to userAgent), session) + useAgent(userAgent, session) // Pictures, fonts and media are not needed to find links, as on Android call("Network.setBlockedURLs", mapOf("urls" to BLOCKED), session) val extra = request.headers.toMap().filterKeys { it.lowercase() !in setOf("user-agent", "referer", "cookie") } @@ -124,8 +123,7 @@ class SystemBrowser( val referer = request.header("Referer") call("Page.navigate", buildMap { put("url", request.url.toString()); if (referer != null) put("referrer", referer) }, session) - val shown = CompletableDeferred() - val checker = showWhenChallenged(request, target, session, shown) + val checker = reportChallenge(request, session) try { withTimeoutOrNull(timeoutMs) { for (event in events) { @@ -140,30 +138,25 @@ class SystemBrowser( } } finally { checker.cancel() - if (shown.isCompleted) runCatching { place(target, onScreen = false, session) } } } - /** A page still on Cloudflare's check after a few seconds needs the user: the window comes on screen */ - private fun showWhenChallenged(request: Request, target: String, session: String, shown: CompletableDeferred) = scope.launch { - delay(SHOW_AFTER_MS) - val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() - if (CHALLENGE_TITLES.any { title.contains(it, ignoreCase = true) }) { - DesktopAndroid.toastHandler?.invoke("${request.url.host} checks you are human: tick the box in the browser window") - runCatching { place(target, onScreen = true, session) } - shown.complete(Unit) - } + /** The extension's user agent, else the browser's own without "Headless", which sites refuse */ + private suspend fun useAgent(agent: String?, session: String) { + (agent ?: userAgent)?.let { call("Network.setUserAgentOverride", mapOf("userAgent" to it), session) } } - /** Moves the page's window on screen for the user, or back off screen */ - private suspend fun place(target: String, onScreen: Boolean, session: String) { - val window = call("Browser.getWindowForTarget", mapOf("targetId" to target)).path("windowId").asInt() - val bounds = if (onScreen) mapOf("left" to 120, "top" to 80, "width" to 900, "height" to 720) else mapOf("left" to OFF_SCREEN, "top" to OFF_SCREEN) - call("Browser.setWindowBounds", mapOf("windowId" to window, "bounds" to bounds + ("windowState" to "normal"))) - if (onScreen) { - connection?.process?.pid()?.let { onShow?.invoke(it) } - call("Target.activateTarget", mapOf("targetId" to target)) - call("Page.bringToFront", session = session) + /** + * A page still on Cloudflare's check after a while wants a box ticked, which a browser without a + * window cannot do: the user is told once per site why its links do not come + */ + private fun reportChallenge(request: Request, session: String) = scope.launch { + delay(CHALLENGE_REPORT_MS) + val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() + val host = request.url.host + if (CHALLENGE_TITLES.any { title.contains(it, ignoreCase = true) } && reportedHosts.add(host)) { + println("WARN SystemBrowser: $host wants a human check, which cannot be done without a window") + DesktopAndroid.toastHandler?.invoke("$host asks to check you are human, so its links cannot be loaded") } } @@ -200,6 +193,7 @@ class SystemBrowser( val events = Channel(Channel.UNLIMITED) sessions[session] = events try { + useAgent(null, session) return fetchIn(target, session, events, request) } finally { sessions.remove(session) @@ -234,8 +228,7 @@ class SystemBrowser( // The page's last document: Cloudflare's check marks its page, and loads the real one once passed var document: JsonNode? = null var finished = false - val shown = CompletableDeferred() - val checker = showWhenChallenged(request, target, session, shown) + val checker = reportChallenge(request, session) try { withTimeoutOrNull(FETCH_TIMEOUT_MS) { for (event in events) { @@ -262,7 +255,6 @@ class SystemBrowser( } } finally { checker.cancel() - if (shown.isCompleted) runCatching { place(target, onScreen = false, session) } } val done = document?.takeIf { finished } ?: return null val response = done.path("response") @@ -372,12 +364,12 @@ class SystemBrowser( } private suspend fun start() = startLock.withLock { - connection?.takeIf { it.process.isAlive }?.let { return } + connection?.takeIf { it.browser?.isAlive != false }?.let { return } connection = null profile.mkdirs() val portFile = profile.resolve("DevToolsActivePort") portFile.delete() - val process = ProcessBuilder( + val launcher = ProcessBuilder( executable.absolutePath, "--remote-debugging-port=0", "--user-data-dir=${profile.absolutePath}", @@ -387,15 +379,16 @@ class SystemBrowser( "--disable-sync", "--hide-crash-restore-bubble", "--disable-features=msImplicitSignin,msEdgeWelcomePage", - if (hidden) "--window-position=$OFF_SCREEN,$OFF_SCREEN" else "--window-position=120,80", + if (headless) "--headless=new" else "--window-position=120,80", "--window-size=1100,800", "about:blank", ).redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start() - // The browser writes its DevTools port and address once it listens - val lines = withTimeoutOrNull(START_TIMEOUT_MS) { devToolsAddress(portFile, process) } + // The browser writes its DevTools port and address once it listens. Headless Edge starts + // its browser as another process and the one started here exits, so it is not waited on + val lines = withTimeoutOrNull(START_TIMEOUT_MS) { devToolsAddress(portFile) } if (lines == null) { - process.destroyForcibly() + launcher.destroyForcibly() throw IllegalStateException("${executable.name} did not start") } val opened = CompletableDeferred() @@ -427,18 +420,21 @@ class SystemBrowser( }, ) withTimeout(START_TIMEOUT_MS) { opened.await() } - connection = Connection(process, socket) - userAgent = runCatching { call("Browser.getVersion").path("userAgent").asText() }.getOrNull() + connection = Connection(launcher.toHandle().takeIf { it.isAlive }, socket) + // The browser's own process, to close it if it does not close when asked + runCatching { + call("SystemInfo.getProcessInfo").path("processInfo").firstOrNull { it.path("type").asText() == "browser" }?.path("id")?.asLong() + }.getOrNull()?.let(ProcessHandle::of)?.orElse(null)?.let { connection = Connection(it, socket) } + userAgent = runCatching { call("Browser.getVersion").path("userAgent").asText().replace("Headless", "") }.getOrNull() println("INFO SystemBrowser: started ${executable.name}, $userAgent") } - private suspend fun devToolsAddress(portFile: File, process: Process): List? { - while (process.isAlive) { + private suspend fun devToolsAddress(portFile: File): List { + while (true) { val lines = runCatching { portFile.readLines() }.getOrNull() if (lines != null && lines.size >= 2) return lines delay(100) } - return null } private fun failAll(error: Throwable) { @@ -452,9 +448,12 @@ class SystemBrowser( val current = connection ?: return connection = null runCatching { current.socket.send("""{"id":0,"method":"Browser.close"}""") } - if (!current.process.waitFor(3, TimeUnit.SECONDS)) { - current.process.descendants().forEach { it.destroyForcibly() } - current.process.destroyForcibly() + current.browser?.let { browser -> + val closed = runCatching { browser.onExit().get(3, TimeUnit.SECONDS) }.isSuccess + if (!closed) { + browser.descendants().forEach { it.destroyForcibly() } + browser.destroyForcibly() + } } current.socket.cancel() } @@ -463,8 +462,7 @@ class SystemBrowser( (this as? ObjectNode)?.fields()?.asSequence()?.map { it.key to it.value }?.toList().orEmpty() companion object { - private const val OFF_SCREEN = -32000 - private const val SHOW_AFTER_MS = 5_000L + private const val CHALLENGE_REPORT_MS = 15_000L private const val CALL_TIMEOUT_MS = 30_000L private const val START_TIMEOUT_MS = 20_000L private const val IDLE_CLOSE_MS = 3 * 60_000L diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt index e04274b02a6..b1cc1ca20e7 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt @@ -11,6 +11,7 @@ import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -64,6 +65,9 @@ class SystemBrowserTest { assertEquals(listOf("$base/player"), extra.map { it.url.toString() }) assertTrue(browser.cookies("$base/player").orEmpty().contains("seen=yes")) assertTrue(WebViewResolver.webViewUserAgent.orEmpty().contains("Mozilla")) + // Sites refuse a headless browser, which says so in its user agent + assertFalse(WebViewResolver.webViewUserAgent.orEmpty().contains("Headless")) + assertFalse(found.header("User-Agent").orEmpty().contains("Headless")) } /** A test page behind Cloudflare's check (it answers 403 to plain clients), so only with -PcloudflareTest as well */ From e2283870d34bca4a7b70f35633cb26daf95d92cb Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 19:04:47 +0530 Subject: [PATCH 28/64] Desktop: sites that want a human check come last, and a browser window opens only when the user asks A page whose Cloudflare check wants a box ticked now gives up at once (after 8 s, when the check has not passed by itself) instead of waiting out its timeout, and its site is reported. The player keeps those sites apart: other links load and play first, and only once nothing else plays does it offer "Open browser". The browser then opens a window on each site in turn; the cookies of a passed check go back to the headless browser and the links are loaded again, skipping those that failed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 3 +- .../kotlin/com/lagradost/cloudstream4/Main.kt | 5 +- .../cloudstream4/WindowsFullscreen.kt | 8 + .../cloudstream4/player/PlayerScreen.kt | 36 ++++ .../cloudstream4/player/HumanChecks.kt | 15 ++ .../cloudstream4/player/LinksViewModel.kt | 38 ++++- .../cloudstream4/browser/SystemBrowser.kt | 155 +++++++++++++++--- .../cloudstream4/PlaybackViewModelTest.kt | 43 +++++ .../cloudstream4/browser/SystemBrowserTest.kt | 37 +++++ 9 files changed, 316 insertions(+), 24 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/HumanChecks.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 4067945925b..c2924e020df 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -66,6 +66,7 @@ import com.lagradost.cloudstream4.search.DataStoreSearchHistory import com.lagradost.cloudstream4.search.SearchViewModel import com.lagradost.cloudstream4.settings.ExtensionsScreen import com.lagradost.cloudstream4.settings.SettingsHomeScreen +import com.lagradost.cloudstream4.browser.SystemBrowser import com.lagradost.cloudstream4.torrent.TorrServer import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -347,7 +348,7 @@ private fun PageContent(page: Page, navigator: AppNavigator) { ) is Page.Player -> PlayerScreen( - viewModel = viewModel { LinksViewModel(api, page.request, torrents = TorrServer.supported) }, + viewModel = viewModel { LinksViewModel(api, page.request, torrents = TorrServer.supported, checks = SystemBrowser.instance) }, fullscreen = navigator.fullscreen, onBack = { navigator.back() }, onToggleFullscreen = navigator::toggleFullscreen, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 34a014e7549..a08cc198342 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -63,8 +63,9 @@ fun main() { // Before the extensions load: they make their requests through it @OptIn(DelicateCoroutinesApi::class) DesktopHttp.start(GlobalScope) - // Edge or Chrome, without a window, stands in for Android's WebView, for extensions behind Cloudflare and the like - SystemBrowser.install() + // Edge or Chrome, without a window, stands in for Android's WebView, for extensions behind Cloudflare and the like. + // It only shows a window when the user asks to pass a site's check, and lets that window come in front of the app + SystemBrowser.install()?.onShow = WindowsForeground::allow // Plugins load in the background so a slow or broken plugin never delays the window @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt index 5a4668504c1..d32a557ae2f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsFullscreen.kt @@ -18,6 +18,7 @@ private interface User32 : StdCallLibrary { fun ShowWindow(hwnd: Pointer, cmd: Int): Boolean fun IsZoomed(hwnd: Pointer): Boolean fun SetForegroundWindow(hwnd: Pointer): Boolean + fun AllowSetForegroundWindow(processId: Int): Boolean } private val user32: User32? by lazy { @@ -131,3 +132,10 @@ object WindowsHide { user32.SetForegroundWindow(hwnd) } } + +/** Lets another program's window come in front of the app's, which Windows otherwise refuses it */ +object WindowsForeground { + fun allow(processId: Long) { + user32?.AllowSetForegroundWindow(processId.toInt()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 2ddc2227417..6f11e1ce297 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -372,6 +372,8 @@ fun PlayerScreen( if (showBars) TopBar( title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), line = subtitleMessage ?: when { + state.exhausted && state.checkSites.isNotEmpty() -> + "More links wait on a check that you are human" + if (state.links.isNotEmpty()) ", the other ${state.links.size} could not be played" else "" state.exhausted && state.links.isEmpty() -> "No playable links found" + if (state.skipped > 0) " (${state.skipped} links cannot play on desktop, such as DRM)" else "" state.exhausted -> "None of the ${state.links.size} links could be played" @@ -387,6 +389,11 @@ fun PlayerScreen( // The video is a native window, so nothing can be drawn over it: panels open beside it Row(Modifier.fillMaxWidth().weight(1f)) { SwingPanel(factory = { player.canvas }, modifier = Modifier.weight(1f).fillMaxHeight(), background = Color.Black) + if (state.exhausted && state.checkSites.isNotEmpty()) CheckPrompt( + sites = state.checkSites, + checking = state.checking, + onOpen = { viewModel.onAction(LinksAction.PassChecks) }, + ) if (askTorrent) TorrentPrompt( onPlay = { TorrServer.accepted = true @@ -455,6 +462,35 @@ private fun TorrentPrompt(onPlay: () -> Unit, onSkip: () -> Unit) { } } +/** + * Offered once nothing else plays: sites that only give their links after a check that a person has to + * pass, such as Cloudflare's tick box. The browser opens a window only when the user asks for it here + */ +@Composable +private fun CheckPrompt(sites: List, checking: Boolean, onOpen: () -> Unit) { + Column( + Modifier.width(360.dp).fillMaxHeight().background(MaterialTheme.colorScheme.surface).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Text("Open a browser for more links?", style = MaterialTheme.typography.titleLarge) + Text( + "${if (sites.size == 1) "This site wants" else "These sites want"} to check that you are human " + + "before giving their links:", + style = MaterialTheme.typography.bodyMedium, + ) + Text(sites.joinToString(separator = "\n"), style = MaterialTheme.typography.bodyMedium) + Text( + "A browser window opens on each in turn. Tick the box there: the window closes by itself once the check " + + "is passed, and their links load here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onOpen, enabled = !checking, modifier = Modifier.fillMaxWidth()) { + Text(if (checking) "Waiting for the check…" else "Open browser") + } + } +} + /** "English", or "English 1", "English 2" when there are several, by url */ private fun subtitleLabels(subtitles: List): Map { // Extensions spell a language with stray spaces at times, which must not make it a second language diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/HumanChecks.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/HumanChecks.kt new file mode 100644 index 00000000000..acefbabe446 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/HumanChecks.kt @@ -0,0 +1,15 @@ +package com.lagradost.cloudstream4.player + +import kotlinx.coroutines.flow.Flow + +/** + * Sites whose links wait on a check only a person can pass, such as Cloudflare's tick box. Their links + * come last: the user is only asked to pass the checks once nothing else is left to play. + */ +interface HumanChecks { + /** A site's host, each time one asks for a check that did not pass by itself */ + val asked: Flow + + /** Lets the user pass the site's check, in a window. True once passed */ + suspend fun pass(host: String): Boolean +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt index f2624043d6e..8828672af6c 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt @@ -13,6 +13,7 @@ import com.lagradost.cloudstream4.compose.DefaultStateContainer import com.lagradost.cloudstream4.compose.StateContainer import com.lagradost.cloudstream4.detail.PlayRequest import com.lagradost.cloudstream4.providers.loadLinksSafely +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -31,6 +32,13 @@ data class LinksState( val failed: Map = emptyMap(), /** Links found that the player cannot use, such as DRM, or torrents where they cannot play */ val skipped: Int = 0, + /** + * Sites whose links wait on a check only a person can pass. They come last: the user is offered + * to pass them once nothing else is left to play + */ + val checkSites: List = emptyList(), + /** The user is passing those checks */ + val checking: Boolean = false, ) { val selectedLink: ExtractorLink? get() = links.firstOrNull { it.url == selected } @@ -43,6 +51,8 @@ sealed interface LinksAction { /** The player could not play this link, so the next one is tried */ data class Failed(val url: String, val reason: String) : LinksAction data object Retry : LinksAction + /** Pass the checks of [LinksState.checkSites], then look for links again */ + data object PassChecks : LinksAction } /** @@ -57,6 +67,8 @@ class LinksViewModel( private val autoPickAfter: Duration = 6.seconds, /** Torrents and magnet links can play, after every other link */ private val torrents: Boolean = false, + /** Checks sites can ask for, which the user can pass. Without, such sites give no links */ + private val checks: HumanChecks? = null, ) : ViewModel(), StateContainer by DefaultStateContainer(LinksState()), ActionHandler { private var loadJob: Job? = null @@ -78,10 +90,25 @@ class LinksViewModel( } LinksAction.Retry -> load() + LinksAction.PassChecks -> passChecks() } } - private fun load() { + private fun passChecks() { + val checks = checks ?: return + if (state.value.checking) return + val sites = state.value.checkSites + updateState { copy(checking = true) } + viewModelScope.launch { + // One window after another, each closing once its check is passed + val passed = sites.filter { checks.pass(it) } + updateState { copy(checking = false) } + // Their links come now. What failed already is not tried again + if (passed.isNotEmpty()) load(keepFailed = true) + } + } + + private fun load(keepFailed: Boolean = false) { loadJob?.cancel() autoPicking = false val local = request.localFile @@ -96,8 +123,14 @@ class LinksViewModel( updateState { LinksState(loading = false) } return } - updateState { LinksState() } + updateState { LinksState(failed = if (keepFailed) failed else emptyMap()) } loadJob = viewModelScope.launch { + // Subscribed before the extension runs, so no site is missed + val watcher = checks?.let { checks -> + launch(start = CoroutineStart.UNDISPATCHED) { + checks.asked.collect { site -> updateState { if (site in checkSites) this else copy(checkSites = checkSites + site) } } + } + } val picker = launch { // Some extractors take minutes, so do not wait for all of them once a link is in state.first { it.links.isNotEmpty() } @@ -112,6 +145,7 @@ class LinksViewModel( onLink = ::addLink, ) picker.cancel() + watcher?.cancel() // One update, so nobody sees loading finished with nothing picked yet autoPicking = true updateState { copy(loading = false, selected = selected ?: nextLink(links, failed)) } diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt index e8f9e1e3531..f8e97f7cf8f 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt @@ -5,14 +5,19 @@ import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.lagradost.cloudstream3.network.DesktopWebView import com.lagradost.cloudstream4.AppDirs -import com.lagradost.cloudstream4.android.DesktopAndroid +import com.lagradost.cloudstream4.player.HumanChecks import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -39,16 +44,18 @@ import java.util.concurrent.atomic.AtomicInteger /** * The browser behind extensions' WebViewResolver and CloudflareKiller on desktop: Microsoft Edge, * which comes with Windows, or Chrome, run with its own profile in the app's cache folder and driven - * through the DevTools protocol. It runs headless, without a window: Cloudflare checks that pass by - * themselves are passed, one that wants a box ticked fails like a site that is down. The browser - * closes after a few idle minutes, and when the app exits. + * through the DevTools protocol. It runs headless, without a window, and Cloudflare checks that pass + * by themselves are passed. A page whose check wants a box ticked fails at once, and its site is + * [asked]: the player leaves those sites for last, and only when nothing else plays offers to [pass] + * their checks, in a window. The cookies of a passed check come back here. The browser closes after + * a few idle minutes, and when the app exits. */ class SystemBrowser( private val executable: File, private val profile: File = AppDirs.cache.resolve("browser").toFile(), /** Without a window. False shows the browser, to watch what a page does */ private val headless: Boolean = true, -) : DesktopWebView { +) : DesktopWebView, HumanChecks { private val json = jacksonObjectMapper() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val startLock = Mutex() @@ -64,11 +71,19 @@ class SystemBrowser( private val sessions = ConcurrentHashMap>() private val loads = AtomicInteger(0) private var idleJob: Job? = null - private val reportedHosts = ConcurrentHashMap.newKeySet() + /** A check per site at a time in a window: the cookies once passed, else null */ + private val checks = ConcurrentHashMap?>>() + /** Sites whose check wants a person, with the page that asked and the user agent it had */ + private val blocked = ConcurrentHashMap>() + private val _asked = MutableSharedFlow(extraBufferCapacity = 32) + override val asked = _asked.asSharedFlow() @Volatile override var userAgent: String? = null private set + /** Called with the browser's process id before its window comes forward, which Windows must allow */ + @Volatile var onShow: ((Long) -> Unit)? = null + override suspend fun load( request: Request, userAgent: String?, @@ -123,9 +138,10 @@ class SystemBrowser( val referer = request.header("Referer") call("Page.navigate", buildMap { put("url", request.url.toString()); if (referer != null) put("referrer", referer) }, session) - val checker = reportChallenge(request, session) + val deadline = Deadline(timeoutMs) + val checker = handleChallenge(request.url, userAgent ?: this.userAgent, session, deadline) try { - withTimeoutOrNull(timeoutMs) { + untilDeadline(deadline) { for (event in events) { if (event.path("method").asText() != "Network.requestWillBeSent") continue val sent = event.path("params").path("request") @@ -146,20 +162,112 @@ class SystemBrowser( (agent ?: userAgent)?.let { call("Network.setUserAgentOverride", mapOf("userAgent" to it), session) } } + /** When a page gives up waiting: after its time, or earlier, once its site wants a check only a person can pass */ + private class Deadline(val ms: Long) { + val ended = CompletableDeferred() + fun end() { + ended.complete(Unit) + } + } + + /** Runs [block] until it finishes, its time runs out, or the [deadline] is ended early */ + private suspend fun untilDeadline(deadline: Deadline, block: suspend () -> Unit) = coroutineScope { + val work = launch { withTimeoutOrNull(deadline.ms) { block() } } + val stopper = launch { + deadline.ended.await() + work.cancel() + } + work.join() + stopper.cancel() + } + + private suspend fun challenged(session: String): Boolean { + val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() + return CHALLENGE_TITLES.any { title.contains(it, ignoreCase = true) } + } + /** * A page still on Cloudflare's check after a while wants a box ticked, which a browser without a - * window cannot do: the user is told once per site why its links do not come + * window cannot do. The page stops waiting, like a site that is down, and the site is [asked], so + * the user can [pass] its check once nothing else plays. */ - private fun reportChallenge(request: Request, session: String) = scope.launch { - delay(CHALLENGE_REPORT_MS) - val title = runCatching { evaluate("document.title", session) }.getOrNull().orEmpty() - val host = request.url.host - if (CHALLENGE_TITLES.any { title.contains(it, ignoreCase = true) } && reportedHosts.add(host)) { - println("WARN SystemBrowser: $host wants a human check, which cannot be done without a window") - DesktopAndroid.toastHandler?.invoke("$host asks to check you are human, so its links cannot be loaded") + private fun handleChallenge(url: HttpUrl, agent: String?, session: String, deadline: Deadline) = scope.launch { + delay(CHALLENGE_WAIT_MS) + if (!challenged(session)) return@launch + println("INFO SystemBrowser: ${url.host} wants a human check, its links are left for last") + blocked[url.host] = url to agent + _asked.tryEmit(url.host) + deadline.end() + } + + override suspend fun pass(host: String): Boolean { + val (url, agent) = blocked[host] ?: return false + // Several asking at once share one window + val check = checks.computeIfAbsent(host) { + scope.async { + val window = SystemBrowser(executable, profile.resolveSibling("${profile.name}-check"), headless = false) + window.onShow = onShow + window.passInWindow(url, agent) + } + } + val cookies = try { + check.await() + } finally { + checks.remove(host, check) + } ?: return false + return runCatching { + start() + call("Storage.setCookies", mapOf("cookies" to cookies.map(::cookieParam))) + blocked.remove(host) + true + }.getOrElse { + println("WARN SystemBrowser: could not use the cookies of $host's check: $it") + false } } + /** + * Opens [url] in this browser's window for the user to pass the site's check, and closes it after. + * Returns the site's cookies once passed, or null when the window was closed or the time ran out. + */ + private suspend fun passInWindow(url: HttpUrl, agent: String?): List? = try { + start() + val target = call("Target.createTarget", mapOf("url" to "about:blank")).path("targetId").asText() + val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() + // The cookie only counts for the user agent that passed + useAgent(agent, session) + call("Page.navigate", mapOf("url" to url.toString()), session) + val window = call("Browser.getWindowForTarget", mapOf("targetId" to target)).path("windowId").asInt() + call("Browser.setWindowBounds", mapOf("windowId" to window, "bounds" to mapOf("windowState" to "normal"))) + connection?.browser?.pid()?.let { onShow?.invoke(it) } + call("Target.activateTarget", mapOf("targetId" to target)) + call("Page.bringToFront", session = session) + withTimeoutOrNull(CHECK_TIMEOUT_MS) { + while (true) { + delay(1_000) + // Throws once the user closes the window + val title = evaluate("document.title", session).orEmpty() + if (title.isNotBlank() && CHALLENGE_TITLES.none { title.contains(it, ignoreCase = true) }) break + } + val domain = url.host.removePrefix("www.") + call("Storage.getCookies").path("cookies").filter { domain.endsWith(it.path("domain").asText().trimStart('.')) } + }.also { println("INFO SystemBrowser: ${url.host}'s check ${if (it == null) "was not passed in time" else "was passed"}") } + } catch (e: Exception) { + println("INFO SystemBrowser: ${url.host}'s check was not passed: $e") + null + } finally { + stop() + } + + /** A cookie as Storage.getCookies gives it, in the form Storage.setCookies takes */ + private fun cookieParam(cookie: JsonNode): Map = buildMap { + listOf("name", "value", "domain", "path").forEach { put(it, cookie.path(it).asText()) } + put("secure", cookie.path("secure").asBoolean()) + put("httpOnly", cookie.path("httpOnly").asBoolean()) + cookie.path("sameSite").takeIf { it.isTextual }?.let { put("sameSite", it.asText()) } + if (!cookie.path("session").asBoolean()) put("expires", cookie.path("expires").asDouble()) + } + private fun toRequest(sent: JsonNode): Request? = runCatching { val url = sent.path("url").asText() if (!url.startsWith("http")) return null @@ -228,9 +336,10 @@ class SystemBrowser( // The page's last document: Cloudflare's check marks its page, and loads the real one once passed var document: JsonNode? = null var finished = false - val checker = reportChallenge(request, session) + val deadline = Deadline(FETCH_TIMEOUT_MS) + val checker = handleChallenge(url, userAgent, session, deadline) try { - withTimeoutOrNull(FETCH_TIMEOUT_MS) { + untilDeadline(deadline) { for (event in events) { val params = event.path("params") when (event.path("method").asText()) { @@ -462,7 +571,10 @@ class SystemBrowser( (this as? ObjectNode)?.fields()?.asSequence()?.map { it.key to it.value }?.toList().orEmpty() companion object { - private const val CHALLENGE_REPORT_MS = 15_000L + /** A check that passes by itself is done by then */ + private const val CHALLENGE_WAIT_MS = 8_000L + /** How long the user has to pass a check in the window */ + private const val CHECK_TIMEOUT_MS = 3 * 60_000L private const val CALL_TIMEOUT_MS = 30_000L private const val START_TIMEOUT_MS = 20_000L private const val IDLE_CLOSE_MS = 3 * 60_000L @@ -493,10 +605,15 @@ class SystemBrowser( return (windows + others).firstOrNull { it.isFile } } + /** The browser for extensions, once installed */ + @Volatile var instance: SystemBrowser? = null + private set + /** Installs the browser for extensions, when one is found */ fun install(): SystemBrowser? { val executable = find() ?: return null.also { println("WARN SystemBrowser: no Edge or Chrome found, extensions cannot use a WebView") } val browser = SystemBrowser(executable) + instance = browser DesktopWebView.engine = browser Runtime.getRuntime().addShutdownHook(Thread { browser.stop() }) return browser diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt index cc16af3443e..e60f295efdb 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt @@ -25,11 +25,13 @@ import com.lagradost.cloudstream4.detail.PlayRequest import com.lagradost.cloudstream4.library.InMemoryWatchStore import com.lagradost.cloudstream4.library.WatchType import com.lagradost.cloudstream4.library.saveProgress +import com.lagradost.cloudstream4.player.HumanChecks import com.lagradost.cloudstream4.player.LinksAction import com.lagradost.cloudstream4.player.LinksViewModel import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -55,6 +57,8 @@ private class FakeCatalog : MainAPI() { var slowAfter = Int.MAX_VALUE var links: List> = emptyList() var torrents = 0 + /** Runs as links load, like an extractor whose site asks for a check */ + var duringLinks: (suspend () -> Unit)? = null private fun ep(data: String, season: Int?, number: Int, title: String? = null) = newEpisode(data, initializer = { this.season = season; this.episode = number; this.name = title }, fix = false) @@ -81,6 +85,7 @@ private class FakeCatalog : MainAPI() { subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit, ): Boolean { + duringLinks?.invoke() subtitleCallback(newSubtitleFile("English", "https://example.invalid/en.vtt")) subtitleCallback(newSubtitleFile("English", "https://example.invalid/en.vtt")) repeat(torrents) { callback(newExtractorLink("T", "Torrent", "magnet:?xt=$it", ExtractorLinkType.MAGNET)) } @@ -92,6 +97,18 @@ private class FakeCatalog : MainAPI() { } } +/** Sites that ask for a check, and the ones the user passed */ +private class FakeChecks : HumanChecks { + val sites = MutableSharedFlow(extraBufferCapacity = 8) + val passed = mutableListOf() + override val asked = sites + + override suspend fun pass(host: String): Boolean { + passed += host + return true + } +} + @OptIn(ExperimentalCoroutinesApi::class) class PlaybackViewModelTest { private val api = FakeCatalog() @@ -232,6 +249,32 @@ class PlaybackViewModelTest { assertEquals("fast", done.selectedLink?.name) } + @Test + fun sitesThatWantACheckComeLastAndAreOnlyPassedWhenAsked() { + val checks = FakeChecks() + api.links = listOf("a" to 1080) + api.duringLinks = { checks.sites.emit("guarded.example") } + val vm = LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data"), 50.milliseconds, checks = checks) + + // The other links play first, and nothing is asked of the user + val first = await(vm.state) { !it.loading } + assertEquals("a", first.selectedLink?.name) + assertEquals(listOf("guarded.example"), first.checkSites) + assertTrue(checks.passed.isEmpty()) + vm.onAction(LinksAction.Failed("https://example.invalid/a.m3u8", "403")) + assertTrue(vm.state.value.exhausted) + + // Once nothing else plays, the user passes the check, and the site's links come + api.duringLinks = null + api.links = listOf("a" to 1080, "guarded" to 720) + vm.onAction(LinksAction.PassChecks) + val after = await(vm.state) { !it.loading && it.links.size == 2 } + assertEquals(listOf("guarded.example"), checks.passed) + assertTrue(after.checkSites.isEmpty()) + // The link that failed before is not tried again + assertEquals("guarded", after.selectedLink?.name) + } + @Test fun noLinksMeansExhausted() { val vm = links() diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt index b1cc1ca20e7..1412aa9b944 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt @@ -5,6 +5,9 @@ import com.lagradost.cloudstream3.network.CloudflareKiller import com.lagradost.cloudstream3.network.DesktopWebView import com.lagradost.cloudstream3.network.WebViewResolver import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import java.net.InetSocketAddress import java.nio.file.Files @@ -37,6 +40,19 @@ class SystemBrowserTest { exchange.sendResponseHeaders(200, page.size.toLong()) exchange.responseBody.use { it.write(page) } } + // Like Cloudflare's check: passed in a window once the test lets it, which sets a cookie + createContext("/guarded") { exchange -> + val passed = exchange.requestHeaders["Cookie"].orEmpty().any { "passed=1" in it } + if (!passed && guardOpen) exchange.responseHeaders.add("Set-Cookie", "passed=1; Path=/; Max-Age=600") + val page = when { + passed -> """Player""" + guardOpen -> "PassedYou are human" + else -> "Just a moment...Checking your browser" + }.toByteArray() + exchange.responseHeaders.add("Content-Type", "text/html") + exchange.sendResponseHeaders(200, page.size.toLong()) + exchange.responseBody.use { it.write(page) } + } createContext("/stream") { exchange -> exchange.sendResponseHeaders(200, 0) exchange.responseBody.close() @@ -44,6 +60,7 @@ class SystemBrowserTest { start() } private val base = "http://127.0.0.1:${server.address.port}" + @Volatile private var guardOpen = false private val browser = if (enabled) SystemBrowser(SystemBrowser.find()!!, Files.createTempDirectory("cs-browser").toFile()) else null @AfterTest @@ -84,6 +101,26 @@ class SystemBrowserTest { assertEquals(200, response.code) } + /** Opens a browser window for a moment, for the check */ + @Test + fun aSiteThatWantsAPersonGivesUpAtOnceAndIsPassedInAWindowWhenAsked() = runBlocking { + if (browser == null) return@runBlocking + DesktopWebView.engine = browser + val asked = async(start = CoroutineStart.UNDISPATCHED) { browser.asked.first() } + val start = System.nanoTime() + val (blocked, _) = WebViewResolver(Regex("""guarded\.m3u8"""), timeout = 60_000).resolveUsingWebView("$base/guarded") + assertNull(blocked) + // It gave up once the check showed it wants a person, not after the whole minute + assertTrue(System.nanoTime() - start < 30_000_000_000, "gave up after ${(System.nanoTime() - start) / 1_000_000} ms") + assertEquals("127.0.0.1", asked.await()) + + guardOpen = true + assertTrue(browser.pass("127.0.0.1")) + // The cookie of the passed check came back to the browser without a window + val (found, _) = WebViewResolver(Regex("""guarded\.m3u8"""), timeout = 30_000).resolveUsingWebView("$base/guarded") + assertNotNull(found, "the page's request after the check") + } + @Test fun withoutABrowserNothingIsFound() = runBlocking { DesktopWebView.engine = null From aea708f87141fa8923987fb80a8adb28ad83cfa0 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 19:14:25 +0530 Subject: [PATCH 29/64] Desktop: a Skip button while links load, to play the best found so far Some extensions keep finding links for minutes. While links load and at least one is in, the player's top bar shows Skip: it stops finding links and plays the best found so far. Links an extractor still sends after that are left out. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/player/PlayerScreen.kt | 4 ++++ .../cloudstream4/player/LinksViewModel.kt | 14 +++++++++++++- .../cloudstream4/PlaybackViewModelTest.kt | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 6f11e1ce297..9cc23b8b844 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -384,6 +384,8 @@ fun PlayerScreen( loading = state.loading, onBack = onBack, onRetry = if (state.exhausted) ({ viewModel.onAction(LinksAction.Retry) }) else null, + // Some extensions keep finding links for minutes: what is found so far can be enough + onSkip = if (state.loading && state.links.isNotEmpty()) ({ viewModel.onAction(LinksAction.Skip) }) else null, modifier = closePanel, ) // The video is a native window, so nothing can be drawn over it: panels open beside it @@ -574,6 +576,7 @@ private fun TopBar( loading: Boolean, onBack: () -> Unit, onRetry: (() -> Unit)?, + onSkip: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { Row( @@ -597,6 +600,7 @@ private fun TopBar( } } if (loading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = PlayerColors.secondary) + if (onSkip != null) TextButton(onClick = onSkip) { Text("Skip", color = accent()) } if (onRetry != null) TextButton(onClick = onRetry) { Text("Try again", color = accent()) } } } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt index 8828672af6c..dc027aa6d61 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -51,6 +52,8 @@ sealed interface LinksAction { /** The player could not play this link, so the next one is tried */ data class Failed(val url: String, val reason: String) : LinksAction data object Retry : LinksAction + /** Stop finding links: the best found so far plays, and links still coming are left out */ + data object Skip : LinksAction /** Pass the checks of [LinksState.checkSites], then look for links again */ data object PassChecks : LinksAction } @@ -91,9 +94,17 @@ class LinksViewModel( LinksAction.Retry -> load() LinksAction.PassChecks -> passChecks() + LinksAction.Skip -> skip() } } + private fun skip() { + if (!state.value.loading || state.value.links.isEmpty()) return + loadJob?.cancel() + autoPicking = true + updateState { copy(loading = false, selected = selected ?: nextLink(links, failed)) } + } + private fun passChecks() { val checks = checks ?: return if (state.value.checking) return @@ -142,7 +153,8 @@ class LinksViewModel( api.loadLinksSafely( request.data, onSubtitle = { sub -> updateState { if (subtitles.any { it.url == sub.url }) this else copy(subtitles = subtitles + sub) } }, - onLink = ::addLink, + // An extractor can still call back after a skip, from its own thread + onLink = { if (isActive) addLink(it) }, ) picker.cancel() watcher?.cancel() diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt index e60f295efdb..3b0e7e50cae 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt @@ -275,6 +275,25 @@ class PlaybackViewModelTest { assertEquals("guarded", after.selectedLink?.name) } + @Test + fun skipPlaysTheBestFoundSoFarAndStopsFindingLinks() { + api.gate = CompletableDeferred() + api.slowAfter = 2 + // A long wait before picking, so only the skip plays a link + val vm = links("sd" to 720, "hd" to 1080, "late" to 2160, autoPickMs = 60_000) + await(vm.state) { it.links.size == 2 } + assertNull(vm.state.value.selected) + + vm.onAction(LinksAction.Skip) + val skipped = vm.state.value + assertEquals(false, skipped.loading) + assertEquals("hd", skipped.selectedLink?.name) + // The extractor still running is left out + api.gate!!.complete(Unit) + Thread.sleep(200) + assertEquals(listOf("hd", "sd"), vm.state.value.links.map { it.name }) + } + @Test fun noLinksMeansExhausted() { val vm = links() From 9ec53d0820c4d2a3b1782ccc65cb941b379d08dc Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 19:35:11 +0530 Subject: [PATCH 30/64] Desktop: pick the source to download from, as on Android With CineStream, Download sat on "Finding links" for about two minutes, waiting for every extractor, then took the highest quality without asking: for Toy Story 5 a 19.8 GB file, at about 250 KB/s. - Download opens a picker that lists the sources as they are found, best quality first, with their names (which often tell the size). The picked one starts at once: under a second after the pick. - A download only uses the picked source. If it fails, the user picks another, instead of the next link being taken silently. - Resuming finds the links again and carries on with the same source, by url, else by name, as urls often change. If it is gone, the user is asked to pick again. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/browse/DetailScreen.kt | 29 ++- .../browse/DownloadSourcePicker.kt | 120 +++++++++++ .../cloudstream4/browse/DownloadsScreen.kt | 14 +- .../cloudstream4/download/DownloadStore.kt | 8 +- .../cloudstream4/download/Downloads.kt | 196 +++++++++++------- .../cloudstream4/download/DownloadsTest.kt | 91 +++++++- 6 files changed, 361 insertions(+), 97 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index 54c2f4e5364..a8739d81d9f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -107,14 +107,27 @@ private fun Details( } fun downloadButton(episode: Episode?): @Composable () -> Unit = { val id = downloadId(state, episode) - if (id != null) DownloadButton( - state = downloadStates[id], - onStart = { downloadRequest(state, episode)?.let(downloads::start) }, - onPause = { downloads.pause(id) }, - onResume = { downloadRequest(state, episode)?.let(downloads::start) }, - onDelete = { state.titleId?.let { downloads.delete(it, id) } }, - onPlay = { play(viewModel.playRequest(episode), episode) }, - ) + val request = downloadRequest(state, episode) + // The source is picked first, as on Android + var picking by remember { mutableStateOf(false) } + if (id != null && request != null) { + DownloadButton( + state = downloadStates[id], + onStart = { picking = true }, + onPause = { downloads.pause(id) }, + onResume = { downloads.start(request) }, + onDelete = { state.titleId?.let { downloads.delete(it, id) } }, + onPlay = { play(viewModel.playRequest(episode), episode) }, + ) + if (picking) DownloadSourcePicker( + source = request.source, + onPick = { link, subtitles -> + picking = false + downloads.start(request.copy(link = link, subtitles = subtitles)) + }, + onDismiss = { picking = false }, + ) + } } LazyColumn(contentPadding = PaddingValues(bottom = 32.dp), modifier = Modifier.fillMaxSize()) { item { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt new file mode 100644 index 00000000000..435e114022a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt @@ -0,0 +1,120 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream4.download.DownloadSource +import com.lagradost.cloudstream4.download.Downloads +import com.lagradost.cloudstream4.providers.loadLinksSafely +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.net.URI + +/** + * Asks which source to download from, as the Android app does: the best quality can be a file of + * many gigabytes, and a source's name often tells its size. The sources show as they are found, + * best quality first, so one can be picked before every extractor is done. + */ +@Composable +fun DownloadSourcePicker( + source: DownloadSource, + onPick: (ExtractorLink, List) -> Unit, + onDismiss: () -> Unit, +) { + // Links come from the extractors' own threads + val found = remember(source) { MutableStateFlow>(emptyList()) } + val subtitles = remember(source) { MutableStateFlow>(emptyList()) } + var finding by remember(source) { mutableStateOf(true) } + var error by remember(source) { mutableStateOf(null) } + LaunchedEffect(source) { + val api = APIHolder.getApiFromNameNull(source.apiName) + if (api == null) { + error = "${source.apiName} is not loaded" + } else withContext(Dispatchers.IO) { + api.loadLinksSafely( + source.data, + onSubtitle = { sub -> subtitles.update { if (it.any { s -> s.url == sub.url }) it else it + sub } }, + // An extractor can still call back once the picker is closed + onLink = { link -> if (isActive) found.update { Downloads.forPicking(it + link) } }, + ) + } + finding = false + } + val links by found.collectAsState() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Download from") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (finding) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + when { + error != null -> error!! + finding -> "Finding sources… ${links.size} found" + links.isEmpty() -> "No source can be downloaded" + else -> "${links.size} sources" + }, + style = MaterialTheme.typography.bodySmall, + color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyColumn(Modifier.width(520.dp).heightIn(max = 440.dp)) { + items(links, key = { it.url }) { link -> + Column( + Modifier.fillMaxWidth().clickable { onPick(link, subtitles.value) }.padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(link.name.trim(), style = MaterialTheme.typography.bodyMedium, maxLines = 3, overflow = TextOverflow.Ellipsis) + Text(details(link), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + HorizontalDivider() + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** For example "1080p · HLS stream · example.com" */ +private fun details(link: ExtractorLink): String = listOfNotNull( + Qualities.getStringByInt(link.quality).takeIf { it.isNotBlank() }, + if (link.type == ExtractorLinkType.M3U8) "HLS stream" else "Video file", + runCatching { URI(link.url).host?.removePrefix("www.") }.getOrNull(), +).joinToString(" · ") diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt index 0300dd2f276..a5e9eb6bcf4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt @@ -103,7 +103,12 @@ fun DownloadsScreen( state = state, onPlay = { downloadedPlayRequest(downloads, title, episode)?.let(onPlay) }, onPause = { downloads.pause(episode.id) }, - onResume = if (downloads.canResume(title.id, episode.id)) ({ downloads.resume(title.id, episode.id) }) else null, + onResume = when { + // Another source is picked on the title's page + state.pickAgain -> ({ openDetails(title.apiName, title.url) }) + downloads.canResume(title.id, episode.id) -> ({ downloads.resume(title.id, episode.id) }) + else -> null + }, onDelete = { downloads.delete(title.id, episode.id) }, ) } @@ -184,6 +189,8 @@ fun DownloadButton( onResume: () -> Unit, onDelete: () -> Unit, onPlay: () -> Unit, + /** Picks another source, starting the file again */ + onPick: () -> Unit = onStart, ) { var menu by remember { mutableStateOf(false) } Box { @@ -217,9 +224,12 @@ fun DownloadButton( DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { state?.let { Text(statusText(it), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) } if (state?.status == DownloadStatus.Done) DropdownMenuItem(text = { Text("Play downloaded file") }, onClick = { menu = false; onPlay() }) - if (state?.status == DownloadStatus.Paused || state?.status == DownloadStatus.Failed) { + if (state?.status == DownloadStatus.Paused || (state?.status == DownloadStatus.Failed && !state.pickAgain)) { DropdownMenuItem(text = { Text(if (state.status == DownloadStatus.Failed) "Try again" else "Resume") }, onClick = { menu = false; onResume() }) } + if (state?.status == DownloadStatus.Paused || state?.status == DownloadStatus.Failed) { + DropdownMenuItem(text = { Text("Pick another source") }, onClick = { menu = false; onPick() }) + } DropdownMenuItem(text = { Text("Delete download") }, onClick = { menu = false; onDelete() }) } } diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt index 3f2ca84c02e..c503ea1e980 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/DownloadStore.kt @@ -48,6 +48,8 @@ data class DownloadFile( val segmentBytes: Long? = null, /** The url hash of the link it came from: carrying on with another link would mix two files */ val linkHash: Int? = null, + /** The name of the source the user picked, to find it again when its url has changed */ + val linkName: String? = null, ) { val folder: File get() = if (relativePath.isBlank()) root else root.resolve(relativePath) val file: File get() = folder.resolve(displayName) @@ -98,6 +100,8 @@ class DownloadStore(private val context: Context = DesktopAndroid.application) { @JsonProperty("linkHash") val linkHash: Int? = null, /** Desktop only, Android ignores it */ @JsonProperty("segmentBytes") val segmentBytes: Long? = null, + /** Desktop only */ + @JsonProperty("linkName") val linkName: String? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -142,7 +146,7 @@ class DownloadStore(private val context: Context = DesktopAndroid.application) { fun saveFile(id: Int, file: DownloadFile) { context.setKey( "$INFO/$id", - FileInfo(file.totalBytes, file.relativePath, file.displayName, file.segmentsDone?.toString(), file.root.absolutePath, file.linkHash, file.segmentBytes), + FileInfo(file.totalBytes, file.relativePath, file.displayName, file.segmentsDone?.toString(), file.root.absolutePath, file.linkHash, file.segmentBytes, file.linkName), ) } @@ -150,7 +154,7 @@ class DownloadStore(private val context: Context = DesktopAndroid.application) { val info = context.getKey("$INFO/$id", FileInfo::class.java) ?: return null // Android keeps a content:// uri or nothing for its own Downloads folder, which desktop cannot use val root = info.basePath?.takeUnless { it.isBlank() || it.startsWith("content:") }?.let(::File) ?: return null - return DownloadFile(info.totalBytes, root, info.relativePath, info.displayName, info.extraInfo?.toIntOrNull(), info.segmentBytes, info.linkHash) + return DownloadFile(info.totalBytes, root, info.relativePath, info.displayName, info.extraInfo?.toIntOrNull(), info.segmentBytes, info.linkHash, info.linkName) } /** Forgets the file and the episode. The title stays, Continue watching may use it */ diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt index cf52e25bc84..bdda6d6b3c9 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -39,13 +40,25 @@ data class DownloadState( val total: Long = 0, /** Why it failed */ val message: String? = null, + /** Failed because the source picked is gone: the user picks one again */ + val pickAgain: Boolean = false, ) { val fraction: Float? get() = if (total > 0) (bytes.toFloat() / total).coerceIn(0f, 1f) else null val active: Boolean get() = status == DownloadStatus.Queued || status == DownloadStatus.FindingLinks || status == DownloadStatus.Downloading } -/** What to download: an episode, or a movie, and what finds its links */ -data class DownloadRequest(val title: DownloadedTitle, val episode: DownloadedEpisode, val source: DownloadSource) +/** + * What to download: an episode, or a movie, and what finds its links. [link] is the source the user + * picked, with the [subtitles] found beside it. Without, as when resuming, the links are found again + * and the download carries on with the source picked before + */ +data class DownloadRequest( + val title: DownloadedTitle, + val episode: DownloadedEpisode, + val source: DownloadSource, + val link: ExtractorLink? = null, + val subtitles: List = emptyList(), +) /** Finds the links and subtitles of a download, as the player does */ fun interface LinkFinder { @@ -53,10 +66,10 @@ fun interface LinkFinder { } /** - * Downloads episodes and movies to play offline, a few at a time. Each one finds its links, then tries - * them best quality first until one downloads; a download that stopped carries on where it was when - * it is resumed with the same link. Subtitles in the language picked for auto-select are saved - * beside the video, as on Android. + * Downloads episodes and movies to play offline, a few at a time, each from the source the user + * picked, as on Android: the best quality can be a file of many gigabytes. A download that stopped + * carries on where it was when it is resumed: its links are found again, and it goes on with the same + * source. Subtitles in the language picked for auto-select are saved beside the video, as on Android. */ class Downloads( private val store: DownloadStore, @@ -141,7 +154,9 @@ class Downloads( store.saveTitle(request.title) store.saveEpisode(request.episode, request.source) val before = state(id) - set(id, DownloadState(DownloadStatus.Queued, before?.bytes ?: 0, before?.total ?: 0)) + // A new pick starts the file again, unless it is the same link + val restart = request.link != null && store.file(id)?.linkHash?.let { it != request.link.url.hashCode() } == true + set(id, DownloadState(DownloadStatus.Queued, if (restart) 0 else before?.bytes ?: 0, if (restart) 0 else before?.total ?: 0)) jobs[id] = scope.launch { try { slots.withPermit { run(request) } @@ -186,83 +201,108 @@ class Downloads( private suspend fun run(request: DownloadRequest) { val id = request.episode.id - change(id) { copy(status = DownloadStatus.FindingLinks, message = null) } - val links = ArrayList() - val subtitles = ArrayList() - try { - finder.find( - request.source, - onLink = { link -> - synchronized(links) { - if (link !is DrmExtractorLink && Downloader.canDownload(link) && links.none { it.url == link.url }) links += link - } - }, - onSubtitle = { sub -> synchronized(subtitles) { if (subtitles.none { it.url == sub.url }) subtitles += sub } }, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - change(id) { copy(status = DownloadStatus.Failed, message = e.message ?: e.javaClass.simpleName) } - return - } val previous = store.file(id) - // The link it started with first, so it can carry on, then best quality first - val ordered = synchronized(links) { links.toList() } - .sortedWith(compareBy { it.url.hashCode() != previous?.linkHash }.thenByDescending { LinksViewModel.rank(it.quality) }) - if (ordered.isEmpty()) { - change(id) { copy(status = DownloadStatus.Failed, message = "No downloadable links found") } - return + val link: ExtractorLink + val subtitles: List + if (request.link != null) { + link = request.link + subtitles = request.subtitles + } else { + change(id) { copy(status = DownloadStatus.FindingLinks, message = null, pickAgain = false) } + val found = try { + findAgain(request.source, previous) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + change(id) { copy(status = DownloadStatus.Failed, message = e.message ?: e.javaClass.simpleName) } + return + } + if (found == null) { + change(id) { copy(status = DownloadStatus.Failed, message = "The source picked before is gone, pick another", pickAgain = true) } + return + } + link = found.first + subtitles = found.second } val title = request.title val episode = request.episode val name = DownloadNames.fileName(title.name, episode.episode, episode.season, episode.name) - var lastError: String? = null - for (link in ordered) { - val same = previous != null && previous.linkHash == link.url.hashCode() && previous.file.isFile - var file = DownloadFile( - totalBytes = if (same) previous!!.totalBytes else 0, - root = previous?.root ?: root(), - relativePath = previous?.relativePath ?: DownloadNames.folder(title.type, title.name), - displayName = previous?.displayName ?: "$name.mp4", - segmentsDone = if (same) previous!!.segmentsDone else null, - segmentBytes = if (same) previous!!.segmentBytes else null, - linkHash = link.url.hashCode(), - ) - store.saveFile(id, file) - val resume = when { - !same -> null - link.type == ExtractorLinkType.M3U8 -> previous!!.segmentsDone?.let { Downloader.Resume(it, previous.segmentBytes ?: 0) } - else -> Downloader.Resume(0, 0) - } - change(id) { DownloadState(DownloadStatus.Downloading, if (same) file.file.length() else 0, file.totalBytes) } - var saved = 0L - try { - Downloader.download(link, file.file, resume, connections()) { bytes, total, segments -> - file = file.copy(totalBytes = maxOf(total, bytes), segmentsDone = segments ?: file.segmentsDone, segmentBytes = if (segments != null) bytes else file.segmentBytes) - change(id) { DownloadState(DownloadStatus.Downloading, bytes, file.totalBytes) } - // The store writes a whole file, so not on every update - val now = System.nanoTime() - if (now - saved > SAVE_EVERY_NS) { - saved = now - store.saveFile(id, file) - } + val same = previous != null && previous.linkHash == link.url.hashCode() && previous.file.isFile + var file = DownloadFile( + totalBytes = if (same) previous!!.totalBytes else 0, + root = previous?.root ?: root(), + relativePath = previous?.relativePath ?: DownloadNames.folder(title.type, title.name), + displayName = previous?.displayName ?: "$name.mp4", + segmentsDone = if (same) previous!!.segmentsDone else null, + segmentBytes = if (same) previous!!.segmentBytes else null, + linkHash = link.url.hashCode(), + linkName = link.name, + ) + store.saveFile(id, file) + val resume = when { + !same -> null + link.type == ExtractorLinkType.M3U8 -> previous!!.segmentsDone?.let { Downloader.Resume(it, previous.segmentBytes ?: 0) } + else -> Downloader.Resume(0, 0) + } + change(id) { DownloadState(DownloadStatus.Downloading, if (same) file.file.length() else 0, file.totalBytes) } + var saved = 0L + try { + Downloader.download(link, file.file, resume, connections()) { bytes, total, segments -> + file = file.copy(totalBytes = maxOf(total, bytes), segmentsDone = segments ?: file.segmentsDone, segmentBytes = if (segments != null) bytes else file.segmentBytes) + change(id) { DownloadState(DownloadStatus.Downloading, bytes, file.totalBytes) } + // The store writes a whole file, so not on every update + val now = System.nanoTime() + if (now - saved > SAVE_EVERY_NS) { + saved = now + store.saveFile(id, file) } - val length = file.file.length() - store.saveFile(id, file.copy(totalBytes = length)) - saveSubtitles(subtitles, file.copy(totalBytes = length)) - change(id) { DownloadState(DownloadStatus.Done, length, length) } - return - } catch (e: CancellationException) { - store.saveFile(id, file) - throw e - } catch (e: Throwable) { - store.saveFile(id, file) - lastError = e.message ?: e.javaClass.simpleName - println("WARN Downloads: ${link.name} failed for ${title.name}: $lastError") + } + val length = file.file.length() + store.saveFile(id, file.copy(totalBytes = length)) + saveSubtitles(subtitles, file.copy(totalBytes = length)) + change(id) { DownloadState(DownloadStatus.Done, length, length) } + return + } catch (e: CancellationException) { + store.saveFile(id, file) + throw e + } catch (e: Throwable) { + store.saveFile(id, file) + val error = e.message ?: e.javaClass.simpleName + println("WARN Downloads: ${link.name} failed for ${title.name}: $error") + // Another source may work + change(id) { copy(status = DownloadStatus.Failed, message = error, pickAgain = true) } + } + } + + /** + * The source a download started with, among the links found again: the same url, else the same + * name, as a link's url often changes between visits. Null when it is gone + */ + private suspend fun findAgain(source: DownloadSource, previous: DownloadFile?): Pair>? { + if (previous?.linkHash == null && previous?.linkName == null) return null + val subtitles = ArrayList() + var byName: ExtractorLink? = null + var same: ExtractorLink? = null + coroutineScope { + lateinit var finding: Job + finding = launch { + finder.find( + source, + onLink = { link -> + synchronized(subtitles) { + if (link.url.hashCode() == previous.linkHash) same = link + else if (byName == null && link.name == previous.linkName && downloadable(link)) byName = link + } + // The same link is found: no need to wait for the other extractors + if (same != null) finding.cancel() + }, + onSubtitle = { sub -> synchronized(subtitles) { if (subtitles.none { it.url == sub.url }) subtitles += sub } }, + ) } } - change(id) { copy(status = DownloadStatus.Failed, message = lastError) } + val link = synchronized(subtitles) { same ?: byName } ?: return null + return link to synchronized(subtitles) { subtitles.toList() } } /** Up to three subtitles in the language picked for auto-select, named after the video as on Android */ @@ -290,6 +330,12 @@ class Downloads( private val SUBTITLE_EXTENSIONS = setOf("vtt", "srt", "txt", "ass", "ttml", "sbv", "dfxp") private const val SAVE_EVERY_NS = 2_000_000_000L + /** Links that can be saved to a file: not DRM, not torrents or web pages */ + fun downloadable(link: ExtractorLink) = link !is DrmExtractorLink && Downloader.canDownload(link) + + /** Links to pick from, best quality first */ + fun forPicking(links: List) = links.filter(::downloadable).distinctBy { it.url }.sortedByDescending { LinksViewModel.rank(it.quality) } + /** Done when within 1 KB of the full size, as on Android */ fun isComplete(bytes: Long, total: Long) = total > 0 && bytes > 1024 && bytes + 1024 >= total diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt index 4b6bd1862ba..35f805a95ca 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt @@ -77,8 +77,8 @@ class DownloadsTest { dir.deleteRecursively() } - private fun link(path: String, type: ExtractorLinkType = ExtractorLinkType.VIDEO) = runBlocking { - newExtractorLink("Test", "Test", "$base$path", type) { quality = 1080 } + private fun link(path: String, type: ExtractorLinkType = ExtractorLinkType.VIDEO, name: String = "Test", quality: Int = 1080) = runBlocking { + newExtractorLink("Test", name, "$base$path", type) { this.quality = quality } } @Test @@ -125,19 +125,17 @@ class DownloadsTest { } @Test - fun downloadsFromTheFoundLinksAndKeepsTheAndroidKeys() = runBlocking { + fun downloadsThePickedSourceAndKeepsTheAndroidKeys() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val root = File(dir, "Downloads") - val finder = LinkFinder { _, onLink, onSubtitle -> - onLink(link("/page.mp4").apply { quality = 2160 }) - onLink(link("/video.mp4")) - onLink(link("/stream.m3u8", ExtractorLinkType.MAGNET)) - onSubtitle(SubtitleFile("English", "$base/sub.vtt")) - } + // Only for resuming: the picked source downloads without finding links again + val finder = LinkFinder { _, _, _ -> error("not needed") } val downloads = Downloads(DownloadStore(), scope, finder, root = { root }, subtitleLanguage = { "en" }) val title = DownloadedTitle(7, "Test", "$base/show", "A show", TvType.TvSeries, null) val episode = DownloadedEpisode(70, 7, "Pilot", 1, 1, null, null) - downloads.start(DownloadRequest(title, episode, DownloadSource("Test", "data"))) + downloads.start( + DownloadRequest(title, episode, DownloadSource("Test", "data"), link("/video.mp4"), listOf(SubtitleFile("English", "$base/sub.vtt"))), + ) val done = withTimeout(20_000) { downloads.states.first { it[70]?.status == DownloadStatus.Done || it[70]?.status == DownloadStatus.Failed } } assertEquals(DownloadStatus.Done, done[70]?.status, done[70]?.message) @@ -160,4 +158,77 @@ class DownloadsTest { assertTrue(again.titles().isEmpty()) scope.cancel() } + + @Test + fun theSourcesToPickFromAreDownloadableOnesBestFirst() { + val picked = Downloads.forPicking( + listOf( + link("/a.mp4", quality = 720), + link("/t", ExtractorLinkType.MAGNET, quality = 2160), + link("/b.m3u8", ExtractorLinkType.M3U8, quality = 1080), + link("/a.mp4", quality = 720), + ), + ) + assertEquals(listOf("$base/b.m3u8", "$base/a.mp4"), picked.map { it.url }) + } + + @Test + fun aPickedSourceThatFailsAsksForAnotherInsteadOfTakingOne() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val finder = LinkFinder { _, onLink, _ -> onLink(link("/video.mp4", quality = 2160)) } + val downloads = Downloads(DownloadStore(), scope, finder, root = { File(dir, "Downloads") }) + val title = DownloadedTitle(8, "Test", "$base/film", "A film", TvType.Movie, null) + val movie = DownloadedEpisode(8, 8, null, null, null, null, null) + downloads.start(DownloadRequest(title, movie, DownloadSource("Test", "data"), link("/page.mp4"))) + val failed = withTimeout(10_000) { downloads.states.first { it[8]?.status == DownloadStatus.Failed } }[8]!! + assertTrue(failed.pickAgain) + assertNull(downloads.file(8)) + scope.cancel() + } + + @Test + fun resumingCarriesOnWithThePickedSourceFoundByItsName() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val root = File(dir, "Downloads") + var mirror = true + // The source's url changed since it was picked, and a better quality is found too + val finder = LinkFinder { _, onLink, _ -> + onLink(link("/stream.m3u8", ExtractorLinkType.M3U8, name = "Big", quality = 2160)) + if (mirror) onLink(link("/video.mp4?token=new", name = "Mirror A", quality = 720)) + } + val store = DownloadStore() + val title = DownloadedTitle(9, "Test", "$base/film2", "Another film", TvType.Movie, null) + val movie = DownloadedEpisode(9, 9, null, null, null, null, null) + val request = DownloadRequest(title, movie, DownloadSource("Test", "data")) + store.saveTitle(title) + store.saveEpisode(movie, request.source) + val partial = DownloadFile(video.size.toLong(), root, "Movies", "Another film.mp4", linkHash = "$base/video.mp4?token=old".hashCode(), linkName = "Mirror A") + partial.folder.mkdirs() + partial.file.writeBytes(video.copyOf(100_000)) + store.saveFile(9, partial) + + val downloads = Downloads(store, scope, finder, root = { root }) + assertEquals(DownloadStatus.Paused, downloads.state(9)?.status) + downloads.start(request) + val done = withTimeout(20_000) { downloads.states.first { it[9]?.status == DownloadStatus.Done || it[9]?.status == DownloadStatus.Failed } }[9]!! + assertEquals(DownloadStatus.Done, done.status, done.message) + // Another url is another file, so it started again, and not with the bigger quality + assertContentEquals(video, partial.file.readBytes()) + assertTrue(segmentHits.isEmpty()) + + // Gone from the extension: the user picks another, nothing else is taken + downloads.delete(9, 9) + withTimeout(5_000) { while (partial.file.exists()) kotlinx.coroutines.delay(50) } + store.saveTitle(title) + store.saveEpisode(movie, request.source) + partial.file.writeBytes(video.copyOf(100_000)) + store.saveFile(9, partial) + mirror = false + val again = Downloads(store, scope, finder, root = { root }) + again.start(request) + val gone = withTimeout(10_000) { again.states.first { it[9]?.status == DownloadStatus.Failed } }[9]!! + assertTrue(gone.pickAgain) + assertTrue(segmentHits.isEmpty()) + scope.cancel() + } } From 6971fea03509fa659c71942fcf4943c86bef6021 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 19:48:03 +0530 Subject: [PATCH 31/64] Desktop: wait for the subtitle as well when a test deletes a download The files are deleted in the background, the video first. On CI the test looked at the subtitle between the two and found it still there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/download/DownloadsTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt index 35f805a95ca..ef158b26a98 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt @@ -22,7 +22,6 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -151,9 +150,11 @@ class DownloadsTest { assertEquals(DownloadStatus.Done, again.state(70)?.status) assertTrue(again.canResume(7, 70)) + val subtitle = File(root, "TVSeries/A show/Season 1 Episode 1 - Pilot English.vtt") + assertTrue(subtitle.exists()) again.delete(7, 70) - withTimeout(5_000) { while (file.exists()) kotlinx.coroutines.delay(50) } - assertFalse(File(root, "TVSeries/A show/Season 1 Episode 1 - Pilot English.vtt").exists()) + // The files go in the background, the video first + withTimeout(5_000) { while (file.exists() || subtitle.exists()) kotlinx.coroutines.delay(50) } assertNull(again.state(70)) assertTrue(again.titles().isEmpty()) scope.cancel() From d6323757beb6805360858fa86bc984fd2129e584 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 20:21:30 +0530 Subject: [PATCH 32/64] Desktop: the Installed page says when part of an extension could not be converted The converter gives a method it cannot convert, or that is too big to, a body that throws, so the sources that need it fail without saying why. Each Android extension now keeps that count (disabledMethods in installed.json), and the Installed page shows "1 part could not be converted, some sources may not work". CONVERTER_VERSION goes to 2, so extensions installed before are converted once more at the next start and get their count. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/ExtensionsSettings.kt | 6 ++++ .../plugins/DesktopPluginManager.kt | 30 +++++++++++++++---- .../cloudstream4/plugins/PluginTest.kt | 13 ++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt index 7f7910e906e..5d8cf03460e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -381,6 +381,12 @@ object InstalledExtensionsScreen : SearchableSettings { subtitle = error?.let { "Failed to load: $it" } ?: listOfNotNull( "Version ${plugin.version}" + (update?.let { " · version ${it.available.version} available" } ?: ""), "Android build, converted for desktop".takeIf { plugin.isAndroid }, + // What could not be converted throws when used, so the sources needing it fail + when (plugin.disabledMethods) { + 0 -> null + 1 -> "1 part could not be converted, some sources may not work" + else -> "${plugin.disabledMethods} parts could not be converted, some sources may not work" + }, when (providers) { 0 -> null 1 -> "1 provider" diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt index 1257a3af6dc..11713962f30 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/plugins/DesktopPluginManager.kt @@ -50,6 +50,11 @@ data class InstalledPlugin( val iconUrl: String? = null, /** The file it was downloaded from. Android extensions check it, see PluginData.url */ val fileUrl: String? = null, + /** + * Methods of an Android plugin that could not be converted, or were too big to: they throw when + * called, so some of the plugin's sources may not work on desktop + */ + val disabledMethods: Int = 0, ) /** A newer version of an installed plugin in its repository */ @@ -138,11 +143,12 @@ class DesktopPluginManager( // The new version is made ready beside the old one, which keeps working if that fails val newJar = target.resolveSibling("${target.fileName}.new") val newCs3 = cs3.resolveSibling("${cs3.fileName}.new") + var disabled = 0 try { if (android) { cs3.parent.createDirectories() newCs3.writeBytes(bytes) - convert(plugin.internalName, newCs3, newJar) + disabled = convert(plugin.internalName, newCs3, newJar) } else { newJar.writeBytes(bytes) } @@ -161,6 +167,7 @@ class DesktopPluginManager( plugin.internalName, plugin.name, plugin.version, repositoryUrl, isAndroid = android, convertedWith = if (android) CONVERTER_VERSION else 0, iconUrl = plugin.iconUrl, fileUrl = if (android) plugin.androidUrl else plugin.jarUrl, + disabledMethods = disabled, ) writeIndex(_installed.value.filterNot { it.internalName == plugin.internalName } + entry) loadLocked(plugin.internalName)?.let { throw it } @@ -204,7 +211,8 @@ class DesktopPluginManager( } } - private fun convert(internalName: String, cs3: Path, target: Path) { + /** Returns how many methods could not be converted */ + private fun convert(internalName: String, cs3: Path, target: Path): Int { val start = System.nanoTime() val result = try { DexConverter.convert(cs3, target) @@ -215,6 +223,7 @@ class DesktopPluginManager( } val millis = (System.nanoTime() - start) / 1_000_000 println("INFO DesktopPluginManager: Converted $internalName in $millis ms, ${result.failedMethods} methods failed") + return result.failedMethods } /** Converts a plugin again after the converter changed. Without its .cs3 the old jar is kept */ @@ -222,8 +231,12 @@ class DesktopPluginManager( val cs3 = androidFile(plugin.internalName) if (!cs3.exists()) return runCatching { convert(plugin.internalName, cs3, jarFile(plugin.internalName)) } - .onSuccess { - writeIndex(_installed.value.map { if (it.internalName == plugin.internalName) it.copy(convertedWith = CONVERTER_VERSION) else it }) + .onSuccess { disabled -> + writeIndex( + _installed.value.map { + if (it.internalName == plugin.internalName) it.copy(convertedWith = CONVERTER_VERSION, disabledMethods = disabled) else it + }, + ) } .onFailure { println("ERROR DesktopPluginManager: Could not convert ${plugin.internalName} again: $it") } } @@ -260,6 +273,7 @@ class DesktopPluginManager( convertedWith = obj["convertedWith"]?.jsonPrimitive?.int ?: 0, iconUrl = obj["iconUrl"]?.jsonPrimitive?.takeIf { it.isString }?.content, fileUrl = obj["fileUrl"]?.jsonPrimitive?.takeIf { it.isString }?.content, + disabledMethods = obj["disabledMethods"]?.jsonPrimitive?.int ?: 0, ) } }.getOrElse { @@ -280,6 +294,7 @@ class DesktopPluginManager( "convertedWith" to JsonPrimitive(it.convertedWith), "iconUrl" to JsonPrimitive(it.iconUrl), "fileUrl" to JsonPrimitive(it.fileUrl), + "disabledMethods" to JsonPrimitive(it.disabledMethods), ) ) }) @@ -290,8 +305,11 @@ class DesktopPluginManager( } companion object { - /** Raise when the conversion changes, so Android plugins installed before are converted again */ - const val CONVERTER_VERSION = 1 + /** + * Raise when the conversion changes, so Android plugins installed before are converted again. + * 2: the methods that could not be converted are counted (disabledMethods) + */ + const val CONVERTER_VERSION = 2 /** The app's plugin manager, keeping plugins in the plugins folder next to the settings */ val instance: DesktopPluginManager by lazy { diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt index a9e76086e9b..5879d383005 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/plugins/PluginTest.kt @@ -138,6 +138,19 @@ class PluginTest { private fun sha256(bytes: ByteArray) = "sha256-" + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + @Test + fun partsThatCouldNotBeConvertedAreKeptAcrossRestarts() { + val plugins = dir.resolve("plugins").also { Files.createDirectories(it) } + plugins.resolve("installed.json").writeBytes( + """[{"internalName":"Big","name":"Big","version":1,"repositoryUrl":"https://h/repo.json","isAndroid":true, + "convertedWith":${DesktopPluginManager.CONVERTER_VERSION},"disabledMethods":2}, + {"internalName":"Old","name":"Old","version":1,"repositoryUrl":"https://h/repo.json"}]""".toByteArray(), + ) + val manager = DesktopPluginManager(plugins, RepositoryClient()) + // Saved by an older app, without the field: nothing disabled + assertEquals(mapOf("Big" to 2, "Old" to 0), manager.installed.value.associate { it.internalName to it.disabledMethods }) + } + @Test fun installFromRepositoryThenRestartThenUninstall() = runBlocking { val repoUrl = serveRepository(pluginJar(*pluginClasses)) From 8bdb48508f230a7bf00b46f45dd2d3cc21631402 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 20:31:47 +0530 Subject: [PATCH 33/64] Desktop: leave NiceHttp's cancelled-request traces out of the console and log When an extension stops its other requests, after one failed or once it has what it needs, NiceHttp prints "Exception in NiceHttp: java.io.IOException Canceled" and a whole stack trace for each. Android sends that to its system log; on desktop it filled the console and cloudstream.log, about 50 traces at once with Frembed. The app's stdout and stderr now drop those lines and traces, and pass everything else. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/DesktopLog.kt | 6 +-- .../cloudstream4/QuietPrintStream.kt | 42 +++++++++++++++++++ .../cloudstream4/QuietPrintStreamTest.kt | 31 ++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/QuietPrintStream.kt create mode 100644 shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/QuietPrintStreamTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt index 7a582bcaf1f..075890f268e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/DesktopLog.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream4 import java.io.BufferedOutputStream import java.io.FileOutputStream import java.io.OutputStream -import java.io.PrintStream import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption.REPLACE_EXISTING @@ -27,8 +26,9 @@ object DesktopLog { // Buffered: the timestamps are added a byte at a time, which would otherwise mean a write to // disk per byte. Both streams flush at the end of every line, so the log stays current val log = TimestampedOutputStream(BufferedOutputStream(FileOutputStream(file.toFile()))) - System.setOut(PrintStream(TeeOutputStream(System.out, log), true, Charsets.UTF_8)) - System.setErr(PrintStream(TeeOutputStream(System.err, log), true, Charsets.UTF_8)) + // Without NiceHttp's trace for every cancelled request, see QuietPrintStream + System.setOut(QuietPrintStream(TeeOutputStream(System.out, log))) + System.setErr(QuietPrintStream(TeeOutputStream(System.err, log))) } catch (t: Throwable) { // Logging must never stop the app from starting System.err.println("Could not create log file in $dir: $t") diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/QuietPrintStream.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/QuietPrintStream.kt new file mode 100644 index 00000000000..545b65703bc --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/QuietPrintStream.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream4 + +import java.io.OutputStream +import java.io.PrintStream + +/** + * A console stream that leaves out NiceHttp's noise about cancelled requests. When an extension stops + * its other requests, after one failed or once it has what it needs, NiceHttp prints a line and a + * whole stack trace for each: "Exception in NiceHttp: java.io.IOException Canceled", then + * "java.io.IOException: Canceled" and its frames. Android sends that to its system log, where nobody + * sees it; here it filled the app's log with dozens of traces at once. Every other line passes. + * + * A stack trace is printed under the stream's lock, so its lines come one after another. + */ +class QuietPrintStream(out: OutputStream) : PrintStream(out, true, Charsets.UTF_8) { + /** Inside a dropped trace: its frames and causes go too */ + private var dropping = false + + @Synchronized + override fun println(x: Any?) = filter(x.toString()) { super.println(x) } + + @Synchronized + override fun println(x: String?) = filter(x.toString()) { super.println(x) } + + private inline fun filter(line: String, print: () -> Unit) { + when { + line == CANCELED_LINE -> return + line == CANCELED_TRACE -> dropping = true + // Frames, "... 12 more", suppressed and causes are indented or start with "Caused by" + dropping && (line.startsWith("\t") || line.startsWith("Caused by: ")) -> {} + else -> { + dropping = false + print() + } + } + } + + companion object { + private const val CANCELED_LINE = "Exception in NiceHttp: java.io.IOException Canceled" + private const val CANCELED_TRACE = "java.io.IOException: Canceled" + } +} diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/QuietPrintStreamTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/QuietPrintStreamTest.kt new file mode 100644 index 00000000000..346a2c49499 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/QuietPrintStreamTest.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream4 + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.SocketTimeoutException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QuietPrintStreamTest { + @Test + fun cancelledRequestsLeaveNothingAndEverythingElsePasses() { + val bytes = ByteArrayOutputStream() + val out = QuietPrintStream(bytes) + out.println("INFO before") + // What NiceHttp prints for a request an extension cancelled + out.println("Exception in NiceHttp: java.io.IOException Canceled") + IOException("Canceled").apply { addSuppressed(SocketTimeoutException("Connect timed out")) }.printStackTrace(out) + // A real error is kept, with its trace + IOException("Connection reset").printStackTrace(out) + out.println("INFO after") + out.flush() + + val lines = bytes.toString(Charsets.UTF_8).lines().filter { it.isNotEmpty() } + assertEquals("INFO before", lines.first()) + assertEquals("java.io.IOException: Connection reset", lines[1]) + assertTrue(lines[2].startsWith("\tat ")) + assertEquals("INFO after", lines.last()) + assertTrue(lines.none { "Canceled" in it || "Connect timed out" in it }, lines.joinToString("\n")) + } +} From ef8a5461b2368287db92724fb86bc092f2ba4bc3 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 20:58:48 +0530 Subject: [PATCH 34/64] Build libmpv on the fork and keep the complete source it was built from libmpv-2.dll includes GPL parts, and THIRD-PARTY-NOTICES promises its complete source with every release. shinchiro's builds follow each library's main branch and record no versions, so that source cannot be put together for the DLL the app uses now. The new libmpv workflow builds it with shinchiro's scripts at a pinned commit, in his build image, then writes down every library's repository and commit, and packs the complete source (libraries, build scripts and patches, Rust crates). Run by hand with "publish", it makes a release with the DLL archive, the source and the list. A pull request that changes the workflow builds without publishing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/libmpv.yml | 166 +++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 .github/workflows/libmpv.yml diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml new file mode 100644 index 00000000000..e416cf5fae6 --- /dev/null +++ b/.github/workflows/libmpv.yml @@ -0,0 +1,166 @@ +name: libmpv + +# Builds libmpv-2.dll for the Windows app with shinchiro's mpv-winbuild-cmake, and keeps the complete +# source it was built from. libmpv includes GPL parts (FFmpeg, x264 and others), so every app release +# must come with that source. The build scripts follow each library's main branch and record nothing, +# so the commits are written down here, after the build, from the source folders themselves. +# +# Run it by hand with "publish" to make a release holding the build, its source and the list of +# versions; desktopApp/build.gradle.kts then points at that release. A pull request that changes this +# file runs it without publishing, to check it still builds. + +on: + workflow_dispatch: + inputs: + winbuild_ref: + description: shinchiro/mpv-winbuild-cmake commit with the build scripts + default: 05a60b3cfd04e3e3b89918f4a27f3dde2935dff2 + publish: + description: Publish the build and its source as a release of this repository + type: boolean + default: false + pull_request: + paths: + - .github/workflows/libmpv.yml + +permissions: + contents: read + +env: + WINBUILD_REF: ${{ inputs.winbuild_ref || '05a60b3cfd04e3e3b89918f4a27f3dde2935dff2' }} + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 355 + # shinchiro's build image, with the tools the scripts expect + container: + image: ghcr.io/shinchiro/archlinux:latest + defaults: + run: + shell: bash + outputs: + name: ${{ steps.package.outputs.name }} + steps: + - name: Check out the build scripts + run: | + git config --global --add safe.directory '*' + git clone https://github.com/shinchiro/mpv-winbuild-cmake.git winbuild + git -C winbuild checkout --quiet "$WINBUILD_REF" + git -C winbuild log -1 --format='%H %cI %s' + + - name: Reuse the toolchain of an earlier run + uses: actions/cache/restore@v6 + with: + path: winbuild/build/install + key: gcc-toolchain-${{ env.WINBUILD_REF }}-${{ github.run_id }} + restore-keys: gcc-toolchain-${{ env.WINBUILD_REF }}- + + - name: Configure and download the sources + working-directory: winbuild + run: | + cmake -DTARGET_ARCH=x86_64-w64-mingw32 -DSINGLE_SOURCE_LOCATION=$PWD/src_packages \ + -DRUSTUP_LOCATION=$PWD/rustup -DENABLE_CCACHE=ON --fresh -G Ninja -B build -S . + ninja -C build download || true + + - name: Build the toolchain + working-directory: winbuild + run: if [[ ! -f build/install/bin/cross-gcc ]]; then ninja -C build gcc && rm -rf build/toolchain; fi + + - name: Save the toolchain for later runs + uses: actions/cache/save@v6 + with: + path: winbuild/build/install + key: gcc-toolchain-${{ env.WINBUILD_REF }}-${{ github.run_id }} + + - name: Build mpv + working-directory: winbuild + run: ninja -C build mpv + + - name: Package libmpv + id: package + working-directory: winbuild + run: | + ninja -C build mpv-packaging + mkdir -p ../out + mv build/mpv-dev-x86_64-*.7z ../out/ + name=$(basename ../out/mpv-dev-x86_64-*.7z .7z) + echo "name=$name" >> "$GITHUB_OUTPUT" + (cd ../out && sha256sum "$name.7z" > "$name.7z.sha256") + + # After the build, each source folder holds the commit it was built from: the scripts update a + # library just before building it, and not after + - name: Write down every version + working-directory: winbuild + run: | + name=${{ steps.package.outputs.name }} + { + echo "# Sources of $name.7z" + echo "# Built $(date -u +%Y-%m-%dT%H:%MZ) by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + echo "# Build scripts: https://github.com/shinchiro/mpv-winbuild-cmake $(git rev-parse HEAD)" + echo "# Container: $(cat /etc/os-release | grep ^PRETTY_NAME | cut -d= -f2) (ghcr.io/shinchiro/archlinux)" + echo + for dir in src_packages/*/; do + lib=$(basename "$dir") + if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then + printf '%-24s %s %s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$(git -C "$dir" rev-parse HEAD)" + else + printf '%-24s from an archive, in the source as it was unpacked\n' "$lib" + fi + done + } > ../out/$name-sources.txt + cat ../out/$name-sources.txt + + # Every library's source, the build scripts with their patches, and the Rust crates the Rust + # libraries were built with. Git history is left out, the commits are in the list above. + # Split into parts below GitHub's 2 GB limit per release file + - name: Pack the complete source + working-directory: winbuild + run: | + name=${{ steps.package.outputs.name }} + # The build tree is done with, and the runner's disk is small + rm -rf build + df -h . + 7z a -t7z -mx=7 -mmt=on -v1900m -xr'!.git' ../out/$name-source.7z \ + src_packages CMakeLists.txt README.md cargo_config.in ccache_conf.in exec.in meson_cross.txt.in toolchain.cmake.in \ + cmake packages scripts toolchain rustup/.cargo/registry/src rustup/.cargo/git/checkouts \ + ../out/$name-sources.txt + ls -la ../out + (cd ../out && sha256sum $name-source.7z.* > $name-source.7z.sha256) + + - name: Keep the build + uses: actions/upload-artifact@v7 + with: + name: libmpv + path: out/ + retention-days: 7 + compression-level: 0 + + publish: + needs: build + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: libmpv + path: out + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + NAME: ${{ needs.build.outputs.name }} + run: | + tag="libmpv-${NAME#mpv-dev-x86_64-}" + { + echo "libmpv for the Windows app, built from shinchiro/mpv-winbuild-cmake by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID." + echo + echo "- \`$NAME.7z\`: libmpv-2.dll and its headers, which the app's build downloads" + echo "- \`$NAME-source.7z.*\`: the complete source it was built from (a multi-part 7z, open the .001 part)" + echo "- \`$NAME-sources.txt\`: every library with the commit it was built from" + echo + echo "libmpv-2.dll includes GPL parts, so it is distributed under the GNU GPL version 2 or later." + } > notes.md + gh release create "$tag" out/* --repo "$GITHUB_REPOSITORY" --title "libmpv ${NAME#mpv-dev-x86_64-}" --notes-file notes.md --prerelease From b8431a24a30a0f60a1d488113ffe42b1a617706e Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 21:02:24 +0530 Subject: [PATCH 35/64] Release workflow for the Windows app, and an updater that sees pre-releases Pushing a tag v builds the MSI and publishes it as a GitHub release, with the complete source of the bundled libmpv copied from the libmpv release the build downloads it from; without that source the workflow stops before building. A tag with a suffix makes a pre-release. The updater read releases/latest, which leaves out pre-releases, and the app is released as pre-releases for now. It now reads the release list and takes the highest v that has an installer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 82 +++++++++++++++++++ .../cloudstream4/update/AppUpdater.kt | 21 ++++- .../com/lagradost/cloudstream4/AppDataTest.kt | 16 ++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..e8216731a95 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release + +# Publishes the Windows app. Push a tag v, where is versionName in +# gradle/libs.versions.toml; a tag with a suffix, such as v4.8.0-beta.1, makes a pre-release. The +# release holds the installer and, as THIRD-PARTY-NOTICES.txt promises, the complete source of the +# libmpv-2.dll inside it, copied from the libmpv release the build downloads it from. + +on: + push: + tags: [ 'v*' ] + +permissions: + contents: write + +jobs: + windows: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + + - name: Check the tag matches the app's version + run: | + version=$(grep -E '^versionName' gradle/libs.versions.toml | cut -d'"' -f2) + tag=${GITHUB_REF_NAME#v} + if [[ "$tag" != "$version" && "$tag" != "$version-"* ]]; then + echo "::error::Tag $GITHUB_REF_NAME does not match versionName $version in gradle/libs.versions.toml" + exit 1 + fi + + # Before the build, so a release never goes out without the source it promises + - name: Get the source of the bundled libmpv + env: + GH_TOKEN: ${{ github.token }} + run: | + libmpv=$(grep -o 'releases/download/[^/"]*' desktopApp/build.gradle.kts | head -1 | cut -d/ -f3) + echo "libmpv comes from release $libmpv" + mkdir libmpv-source + gh release download "$libmpv" --repo "$GITHUB_REPOSITORY" --dir libmpv-source --pattern '*-source.7z.*' --pattern '*-sources.txt' + if ! ls libmpv-source/*-source.7z.001 > /dev/null 2>&1; then + echo "::error::Release $libmpv has no libmpv source. Build libmpv with the libmpv workflow (publish) and point desktopApp/build.gradle.kts at it" + exit 1 + fi + ls -la libmpv-source + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: true + + - name: Run tests + run: ./gradlew :library:jvmTest :shared:jvmTest + + - name: Build MSI + run: ./gradlew :desktopApp:packageMsi + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + msi=$(ls desktopApp/build/compose/binaries/main/msi/*.msi) + prerelease=$([[ "$GITHUB_REF_NAME" == *-* ]] && echo --prerelease || true) + { + echo "CloudStream for Windows ${GITHUB_REF_NAME#v}." + echo + echo "Download \`$(basename "$msi")\` and open it to install, or to update an installed version. Installed apps also find this release in Settings." + echo + echo "SHA-256 of the installer: \`$(sha256sum "$msi" | cut -d' ' -f1)\`" + echo + echo "The \`*-source.7z.*\` files are the complete source of the libmpv-2.dll the app plays video with (GPL, see THIRD-PARTY-NOTICES.txt), with the list of every library's version in \`*-sources.txt\`." + } > notes.md + gh release create "$GITHUB_REF_NAME" "$msi" libmpv-source/* --repo "$GITHUB_REPOSITORY" \ + --title "CloudStream ${GITHUB_REF_NAME#v}" --notes-file notes.md $prerelease diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt index a8755555b16..855a3331a6c 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt @@ -37,7 +37,7 @@ class AppUpdateException(message: String) : Exception(message) * MSI, which upgrades the installed app in place because every version shares one upgrade code. */ class AppUpdater( - private val releasesUrl: String = LATEST_RELEASE, + private val releasesUrl: String = RELEASES, private val http: OkHttpClient = DesktopHttp.client.newBuilder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) @@ -52,10 +52,9 @@ class AppUpdater( suspend fun latestRelease(): AppRelease? = withContext(Dispatchers.IO) { val request = Request.Builder().url(releasesUrl).header("Accept", "application/vnd.github+json").build() http.newCall(request).execute().use { response -> - // GitHub answers 404 while the repository has no release if (response.code == 404) return@withContext null if (!response.isSuccessful) throw AppUpdateException("GitHub answered HTTP ${response.code}") - parseRelease(response.body.string()) + newestRelease(response.body.string()) } } @@ -102,7 +101,21 @@ class AppUpdater( companion object { const val REPOSITORY = "harsh-pockt/cloudstream" - const val LATEST_RELEASE = "https://api.github.com/repos/$REPOSITORY/releases/latest" + /** + * Every release, newest first. Not releases/latest, which leaves out pre-releases, and the app is + * released as pre-releases for now + */ + const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30" + + /** + * The highest version among the app's releases: tagged v, with an installer, not a + * draft. The repository's other releases, such as libmpv's, are left out + */ + internal fun newestRelease(text: String): AppRelease? = + Json.parseToJsonElement(text).jsonArray.map { it.jsonObject } + .filter { (it["draft"] as? JsonPrimitive)?.content != "true" && it.string("tag_name")?.startsWith("v") == true } + .mapNotNull { parseRelease(it.toString()) } + .reduceOrNull { best, next -> if (AppVersion.isNewer(next.version, best.version)) next else best } internal fun parseRelease(text: String): AppRelease? { val obj = Json.parseToJsonElement(text).jsonObject diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt index 986b1cb9de6..99c065f71e4 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt @@ -118,4 +118,20 @@ class AppDataTest { assertEquals("abc123", release.sha256) assertNull(AppUpdater.parseRelease("""{"tag_name":"v1","assets":[]}"""), "No installer, no update") } + + @Test + fun theNewestAppReleaseIsPickedIncludingPreReleases() { + fun release(tag: String, prerelease: Boolean = true, draft: Boolean = false, msi: Boolean = true) = + """{"tag_name":"$tag","prerelease":$prerelease,"draft":$draft,"html_url":"https://x/$tag","assets":[""" + + (if (msi) """{"name":"CloudStream.msi","browser_download_url":"https://x/$tag.msi"}""" else "") + "]}" + val list = listOf( + release("libmpv-20261003-git-3186d369f9"), + release("v4.10.0", draft = true), + release("v4.8.1", prerelease = false), + release("v4.9.0"), + release("v4.9.1", msi = false), + ).joinToString(",", "[", "]") + assertEquals("4.9.0", AppUpdater.newestRelease(list)?.version) + assertNull(AppUpdater.newestRelease("[]")) + } } From 641563317790eda7106fd39ad6fd92a590055994 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 21:21:31 +0530 Subject: [PATCH 36/64] libmpv workflow: set a git committer for the patches, and list the commit under them The build applies its patches with git am, which stopped at spirv-cross without a committer. Patched libraries get local commits on top, so the list names the upstream commit they sit on and how many patches. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/libmpv.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml index e416cf5fae6..08a1a3c1123 100644 --- a/.github/workflows/libmpv.yml +++ b/.github/workflows/libmpv.yml @@ -45,6 +45,9 @@ jobs: - name: Check out the build scripts run: | git config --global --add safe.directory '*' + # The scripts apply their patches with git am, which needs a committer + git config --global user.name "github-actions" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git clone https://github.com/shinchiro/mpv-winbuild-cmake.git winbuild git -C winbuild checkout --quiet "$WINBUILD_REF" git -C winbuild log -1 --format='%H %cI %s' @@ -89,7 +92,8 @@ jobs: (cd ../out && sha256sum "$name.7z" > "$name.7z.sha256") # After the build, each source folder holds the commit it was built from: the scripts update a - # library just before building it, and not after + # library just before building it, and not after. Where they patch a library with git am, the + # patches sit on top as commits of their own: the list names the library's commit under them - name: Write down every version working-directory: winbuild run: | @@ -103,7 +107,10 @@ jobs: for dir in src_packages/*/; do lib=$(basename "$dir") if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then - printf '%-24s %s %s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$(git -C "$dir" rev-parse HEAD)" + base=$(git -C "$dir" merge-base HEAD '@{u}' 2> /dev/null || git -C "$dir" rev-parse HEAD) + patches=$(git -C "$dir" rev-list --count "$base..HEAD") + printf '%-24s %s %s%s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" \ + "$([[ $patches -gt 0 ]] && echo " + $patches patches from packages/")" else printf '%-24s from an archive, in the source as it was unpacked\n' "$lib" fi From 1e215c23ce395a9bb3efaac870716c49d1257c6f Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 3 Oct 2026 22:09:09 +0530 Subject: [PATCH 37/64] libmpv workflow: build with Clang, as shinchiro's published builds are Their GCC build has failed since April 2026 (ngtcp2 no longer configures with it), and the builds they publish, which the app ships today, come from Clang. The LLVM toolchain is built in a job of its own and cached, with the source and commits of the runtime libraries it puts into libmpv-2.dll. The build logs are kept when a step fails. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/libmpv.yml | 165 +++++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 27 deletions(-) diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml index 08a1a3c1123..582cc034824 100644 --- a/.github/workflows/libmpv.yml +++ b/.github/workflows/libmpv.yml @@ -5,6 +5,10 @@ name: libmpv # must come with that source. The build scripts follow each library's main branch and record nothing, # so the commits are written down here, after the build, from the source folders themselves. # +# It builds with Clang, as shinchiro's published builds are: their GCC build has not passed since +# April 2026. The LLVM toolchain takes hours, so it is built in a job of its own and kept in the +# cache; it is rebuilt only when the build scripts change or the cache has expired. +# # Run it by hand with "publish" to make a release holding the build, its source and the list of # versions; desktopApp/build.gradle.kts then points at that release. A pull request that changes this # file runs it without publishing, to check it still builds. @@ -28,12 +32,101 @@ permissions: env: WINBUILD_REF: ${{ inputs.winbuild_ref || '05a60b3cfd04e3e3b89918f4a27f3dde2935dff2' }} + TOOLCHAIN_CACHE: clang-toolchain-v1-${{ inputs.winbuild_ref || '05a60b3cfd04e3e3b89918f4a27f3dde2935dff2' }} jobs: - build: + toolchain: runs-on: ubuntu-latest timeout-minutes: 355 # shinchiro's build image, with the tools the scripts expect + container: + image: ghcr.io/shinchiro/archlinux:latest + defaults: + run: + shell: bash + steps: + - name: Look for a toolchain built earlier + id: cache + uses: actions/cache/restore@v6 + with: + path: | + winbuild/clang_root + winbuild/build + key: ${{ env.TOOLCHAIN_CACHE }}-${{ github.run_id }} + restore-keys: ${{ env.TOOLCHAIN_CACHE }}- + lookup-only: true + + - name: Check out the build scripts + if: steps.cache.outputs.cache-matched-key == '' + run: | + git config --global --add safe.directory '*' + # The scripts apply their patches with git am, which needs a committer + git config --global user.name "github-actions" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git clone https://github.com/shinchiro/mpv-winbuild-cmake.git winbuild + git -C winbuild checkout --quiet "$WINBUILD_REF" + + # As shinchiro's llvm_clang.yml, without the profile-guided second pass: that makes the + # compiler faster, it does not change what the compiler builds + - name: Build LLVM, Clang and the MinGW runtime + if: steps.cache.outputs.cache-matched-key == '' + working-directory: winbuild + run: | + cmake -DTARGET_ARCH=x86_64-w64-mingw32 -DCOMPILER_TOOLCHAIN=clang -DCMAKE_INSTALL_PREFIX=$PWD/clang_root \ + -DMINGW_INSTALL_PREFIX=$PWD/build/x86_64-w64-mingw32 -DSINGLE_SOURCE_LOCATION=$PWD/src_packages \ + -DRUSTUP_LOCATION=$PWD/clang_root/install_rustup -G Ninja -B build -S . + ninja -C build llvm + ninja -C build rustup + ninja -C build cargo-clean + ninja -C build llvm-clang + rm -rf build/toolchain build/packages + + # The runtime libraries built here (libc++, compiler-rt, the MinGW CRT, winpthreads) end up in + # libmpv-2.dll, so their source is kept with the toolchain, with the commits it was built from + - name: Keep the toolchain's source + if: steps.cache.outputs.cache-matched-key == '' + working-directory: winbuild + run: | + for dir in src_packages/*/; do + lib=$(basename "$dir") + if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then + base=$(git -C "$dir" merge-base HEAD '@{u}' 2> /dev/null || git -C "$dir" rev-parse HEAD) + patches=$(git -C "$dir" rev-list --count "$base..HEAD") + printf '%-24s %s %s%s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" \ + "$([[ $patches -gt 0 ]] && echo " + $patches patches from toolchain/")" + else + printf '%-24s from an archive, in the source as it was unpacked\n' "$lib" + fi + done > clang_root/toolchain-sources.txt + cat clang_root/toolchain-sources.txt + 7z a -t7z -mx=7 -mmt=on -xr'!.git' clang_root/toolchain-source.7z src_packages + ls -la clang_root/toolchain-source.7z + + - name: Collect the logs + if: failure() + run: find winbuild/build -type f -name '*-*.log' -print0 | tar -czf toolchain-logs.tar.gz --null -T - || true + + - name: Keep the logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: toolchain-logs + path: toolchain-logs.tar.gz + retention-days: 3 + + - name: Save the toolchain for later runs + if: steps.cache.outputs.cache-matched-key == '' + uses: actions/cache/save@v6 + with: + path: | + winbuild/clang_root + winbuild/build + key: ${{ env.TOOLCHAIN_CACHE }}-${{ github.run_id }} + + build: + needs: toolchain + runs-on: ubuntu-latest + timeout-minutes: 355 container: image: ghcr.io/shinchiro/archlinux:latest defaults: @@ -45,37 +138,32 @@ jobs: - name: Check out the build scripts run: | git config --global --add safe.directory '*' - # The scripts apply their patches with git am, which needs a committer git config --global user.name "github-actions" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git clone https://github.com/shinchiro/mpv-winbuild-cmake.git winbuild git -C winbuild checkout --quiet "$WINBUILD_REF" git -C winbuild log -1 --format='%H %cI %s' - - name: Reuse the toolchain of an earlier run + - name: Restore the toolchain uses: actions/cache/restore@v6 with: - path: winbuild/build/install - key: gcc-toolchain-${{ env.WINBUILD_REF }}-${{ github.run_id }} - restore-keys: gcc-toolchain-${{ env.WINBUILD_REF }}- + path: | + winbuild/clang_root + winbuild/build + key: ${{ env.TOOLCHAIN_CACHE }}-${{ github.run_id }} + restore-keys: ${{ env.TOOLCHAIN_CACHE }}- + fail-on-cache-miss: true + # As shinchiro's mpv_clang.yml - name: Configure and download the sources working-directory: winbuild run: | - cmake -DTARGET_ARCH=x86_64-w64-mingw32 -DSINGLE_SOURCE_LOCATION=$PWD/src_packages \ - -DRUSTUP_LOCATION=$PWD/rustup -DENABLE_CCACHE=ON --fresh -G Ninja -B build -S . + cmake -DTARGET_ARCH=x86_64-w64-mingw32 -DCOMPILER_TOOLCHAIN=clang -DCMAKE_INSTALL_PREFIX=$PWD/clang_root \ + -DMINGW_INSTALL_PREFIX=$PWD/build/x86_64-w64-mingw32 -DSINGLE_SOURCE_LOCATION=$PWD/src_packages \ + -DRUSTUP_LOCATION=$PWD/clang_root/install_rustup -DENABLE_CCACHE=ON -DCLANG_PACKAGES_LTO=ON \ + -G Ninja --fresh -B build -S . ninja -C build download || true - - name: Build the toolchain - working-directory: winbuild - run: if [[ ! -f build/install/bin/cross-gcc ]]; then ninja -C build gcc && rm -rf build/toolchain; fi - - - name: Save the toolchain for later runs - uses: actions/cache/save@v6 - with: - path: winbuild/build/install - key: gcc-toolchain-${{ env.WINBUILD_REF }}-${{ github.run_id }} - - name: Build mpv working-directory: winbuild run: ninja -C build mpv @@ -93,19 +181,26 @@ jobs: # After the build, each source folder holds the commit it was built from: the scripts update a # library just before building it, and not after. Where they patch a library with git am, the - # patches sit on top as commits of their own: the list names the library's commit under them + # patches sit on top as commits of their own: the list names the library's commit under them. + # The toolchain may come from an earlier run, so its commits are those that run wrote down - name: Write down every version working-directory: winbuild run: | name=${{ steps.package.outputs.name }} + toolchain=" $(cut -d' ' -f1 clang_root/toolchain-sources.txt | tr '\n' ' ') " { echo "# Sources of $name.7z" echo "# Built $(date -u +%Y-%m-%dT%H:%MZ) by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" echo "# Build scripts: https://github.com/shinchiro/mpv-winbuild-cmake $(git rev-parse HEAD)" - echo "# Container: $(cat /etc/os-release | grep ^PRETTY_NAME | cut -d= -f2) (ghcr.io/shinchiro/archlinux)" + echo "# Container: $(grep ^PRETTY_NAME /etc/os-release | cut -d= -f2) (ghcr.io/shinchiro/archlinux)" + echo + echo "# Toolchain, its source is in clang_root/toolchain-source.7z" + cat clang_root/toolchain-sources.txt echo + echo "# Libraries" for dir in src_packages/*/; do lib=$(basename "$dir") + [[ "$toolchain" == *" $lib "* ]] && continue if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then base=$(git -C "$dir" merge-base HEAD '@{u}' 2> /dev/null || git -C "$dir" rev-parse HEAD) patches=$(git -C "$dir" rev-list --count "$base..HEAD") @@ -118,23 +213,39 @@ jobs: } > ../out/$name-sources.txt cat ../out/$name-sources.txt - # Every library's source, the build scripts with their patches, and the Rust crates the Rust - # libraries were built with. Git history is left out, the commits are in the list above. - # Split into parts below GitHub's 2 GB limit per release file + # Every library's source, the toolchain's source, the build scripts with their patches, and the + # Rust crates the Rust libraries were built with. Git history is left out, the commits are in the + # list above. The toolchain folders downloaded in this run are left out too: the toolchain was + # built from those in toolchain-source.7z. Split into parts below GitHub's 2 GB limit per file - name: Pack the complete source working-directory: winbuild run: | name=${{ steps.package.outputs.name }} # The build tree is done with, and the runner's disk is small - rm -rf build + rm -rf build/packages df -h . - 7z a -t7z -mx=7 -mmt=on -v1900m -xr'!.git' ../out/$name-source.7z \ + exclude=() + for lib in $(cut -d' ' -f1 clang_root/toolchain-sources.txt); do exclude+=("-x!src_packages/$lib"); done + 7z a -t7z -mx=7 -mmt=on -v1900m -xr'!.git' "${exclude[@]}" ../out/$name-source.7z \ src_packages CMakeLists.txt README.md cargo_config.in ccache_conf.in exec.in meson_cross.txt.in toolchain.cmake.in \ - cmake packages scripts toolchain rustup/.cargo/registry/src rustup/.cargo/git/checkouts \ + cmake packages scripts toolchain clang_root/toolchain-source.7z \ + clang_root/install_rustup/.cargo/registry/src clang_root/install_rustup/.cargo/git/checkouts \ ../out/$name-sources.txt ls -la ../out (cd ../out && sha256sum $name-source.7z.* > $name-source.7z.sha256) + - name: Collect the logs + if: failure() + run: find winbuild/build -type f \( -name '*-*.log' -o -path '*/ffbuild/config.log' \) -print0 | tar -czf build-logs.tar.gz --null -T - || true + + - name: Keep the logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: build-logs + path: build-logs.tar.gz + retention-days: 3 + - name: Keep the build uses: actions/upload-artifact@v7 with: @@ -162,7 +273,7 @@ jobs: run: | tag="libmpv-${NAME#mpv-dev-x86_64-}" { - echo "libmpv for the Windows app, built from shinchiro/mpv-winbuild-cmake by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID." + echo "libmpv for the Windows app, built with Clang from shinchiro/mpv-winbuild-cmake by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID." echo echo "- \`$NAME.7z\`: libmpv-2.dll and its headers, which the app's build downloads" echo "- \`$NAME-source.7z.*\`: the complete source it was built from (a multi-part 7z, open the .001 part)" From e1d84165e32f54004d0d6b22bae869a7db1cdbe8 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 00:13:13 +0530 Subject: [PATCH 38/64] libmpv workflow: hold curl back before it needed a newer OpenSSL curl 978cce12 (2 October 2026) calls ASN1_STRING_get_length, which the OpenSSL commit the build scripts pin does not have, so libcurl stopped compiling. curl is held at its parent with the scripts' own GIT_RESET, as they already do for OpenSSL. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/libmpv.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml index 582cc034824..fa8ec127668 100644 --- a/.github/workflows/libmpv.yml +++ b/.github/workflows/libmpv.yml @@ -144,6 +144,20 @@ jobs: git -C winbuild checkout --quiet "$WINBUILD_REF" git -C winbuild log -1 --format='%H %cI %s' + # A library that follows its main branch can move past what the others it is built with + # support. Each is held back here at a commit, as the scripts already do for OpenSSL: + # - curl 978cce12 (2 October 2026) needs ASN1_STRING_get_length, which the OpenSSL commit the + # scripts pin does not have yet. Held at its parent + - name: Hold back libraries that no longer build + working-directory: winbuild + run: | + for pin in curl=318bd978ea2d36ace36b42da2174b79c3abd89a3; do + lib=${pin%%=*} commit=${pin#*=} + grep -q GIT_RESET packages/$lib.cmake && { echo "packages/$lib.cmake already holds a commit"; exit 1; } + sed -i "0,/GIT_REPOSITORY .*/s//&\n GIT_REMOTE_NAME origin\n GIT_RESET $commit/" packages/$lib.cmake + grep -nE 'GIT_(REPOSITORY|REMOTE_NAME|RESET)' packages/$lib.cmake + done + - name: Restore the toolchain uses: actions/cache/restore@v6 with: From aa48503db33592b1701263fa4c554eef82b829ca Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 01:49:17 +0530 Subject: [PATCH 39/64] libmpv workflow: pack every library's source, and keep the compiler cache Configuring makes an empty folder for every library, so the toolchain job wrote all of them down as toolchain and the build left every library out of its source archive (216 MB). Empty folders are skipped now, and only entries with a commit count as toolchain, which also fixes the list in the toolchain already cached. The Rust git checkouts are packed only when there are any, as 7z fails on a missing path. After a library is built the scripts reset it to its commit without their patches, so the list no longer counts patches on top. The compiler cache is kept between runs, with room for every library, so a run repeated after a failure does not compile everything again. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/libmpv.yml | 54 ++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml index fa8ec127668..9f28f94118e 100644 --- a/.github/workflows/libmpv.yml +++ b/.github/workflows/libmpv.yml @@ -89,11 +89,11 @@ jobs: run: | for dir in src_packages/*/; do lib=$(basename "$dir") + # Configuring makes an empty folder for every library, built here or not + [[ -z "$(ls -A "$dir")" ]] && continue if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then base=$(git -C "$dir" merge-base HEAD '@{u}' 2> /dev/null || git -C "$dir" rev-parse HEAD) - patches=$(git -C "$dir" rev-list --count "$base..HEAD") - printf '%-24s %s %s%s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" \ - "$([[ $patches -gt 0 ]] && echo " + $patches patches from toolchain/")" + printf '%-24s %s %s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" else printf '%-24s from an archive, in the source as it was unpacked\n' "$lib" fi @@ -168,13 +168,22 @@ jobs: restore-keys: ${{ env.TOOLCHAIN_CACHE }}- fail-on-cache-miss: true - # As shinchiro's mpv_clang.yml + # The compiler's cache from the last run: a library that has not changed since is not compiled + # again. Most help when a run is repeated after a failure + - name: Restore the compiler cache + uses: actions/cache/restore@v6 + with: + path: winbuild/build/x86_64-w64-mingw32/ccache + key: libmpv-ccache-${{ github.run_id }} + restore-keys: libmpv-ccache- + + # As shinchiro's mpv_clang.yml, with room in the compiler cache for every library - name: Configure and download the sources working-directory: winbuild run: | cmake -DTARGET_ARCH=x86_64-w64-mingw32 -DCOMPILER_TOOLCHAIN=clang -DCMAKE_INSTALL_PREFIX=$PWD/clang_root \ -DMINGW_INSTALL_PREFIX=$PWD/build/x86_64-w64-mingw32 -DSINGLE_SOURCE_LOCATION=$PWD/src_packages \ - -DRUSTUP_LOCATION=$PWD/clang_root/install_rustup -DENABLE_CCACHE=ON -DCLANG_PACKAGES_LTO=ON \ + -DRUSTUP_LOCATION=$PWD/clang_root/install_rustup -DENABLE_CCACHE=ON -DCCACHE_MAXSIZE=2G -DCLANG_PACKAGES_LTO=ON \ -G Ninja --fresh -B build -S . ninja -C build download || true @@ -182,6 +191,13 @@ jobs: working-directory: winbuild run: ninja -C build mpv + - name: Save the compiler cache + if: ${{ !cancelled() }} + uses: actions/cache/save@v6 + with: + path: winbuild/build/x86_64-w64-mingw32/ccache + key: libmpv-ccache-${{ github.run_id }} + - name: Package libmpv id: package working-directory: winbuild @@ -194,14 +210,16 @@ jobs: (cd ../out && sha256sum "$name.7z" > "$name.7z.sha256") # After the build, each source folder holds the commit it was built from: the scripts update a - # library just before building it, and not after. Where they patch a library with git am, the - # patches sit on top as commits of their own: the list names the library's commit under them. - # The toolchain may come from an earlier run, so its commits are those that run wrote down + # library just before building it, and once built, reset it to that commit without their + # patches, which are in packages/. Configuring makes an empty folder for every library, which + # is skipped. The toolchain may come from an earlier run, so its commits are those that run + # wrote down; the libraries it built are those with a commit there - name: Write down every version working-directory: winbuild run: | name=${{ steps.package.outputs.name }} - toolchain=" $(cut -d' ' -f1 clang_root/toolchain-sources.txt | tr '\n' ' ') " + awk '$2 ~ /^https?:/' clang_root/toolchain-sources.txt > ../toolchain-sources.txt + toolchain=" $(cut -d' ' -f1 ../toolchain-sources.txt | tr '\n' ' ') " { echo "# Sources of $name.7z" echo "# Built $(date -u +%Y-%m-%dT%H:%MZ) by $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" @@ -209,17 +227,15 @@ jobs: echo "# Container: $(grep ^PRETTY_NAME /etc/os-release | cut -d= -f2) (ghcr.io/shinchiro/archlinux)" echo echo "# Toolchain, its source is in clang_root/toolchain-source.7z" - cat clang_root/toolchain-sources.txt + cat ../toolchain-sources.txt echo echo "# Libraries" for dir in src_packages/*/; do lib=$(basename "$dir") - [[ "$toolchain" == *" $lib "* ]] && continue + [[ "$toolchain" == *" $lib "* || -z "$(ls -A "$dir")" ]] && continue if git -C "$dir" rev-parse --git-dir > /dev/null 2>&1 && [[ "$(git -C "$dir" rev-parse --show-toplevel)" == "$(cd "$dir" && pwd)" ]]; then base=$(git -C "$dir" merge-base HEAD '@{u}' 2> /dev/null || git -C "$dir" rev-parse HEAD) - patches=$(git -C "$dir" rev-list --count "$base..HEAD") - printf '%-24s %s %s%s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" \ - "$([[ $patches -gt 0 ]] && echo " + $patches patches from packages/")" + printf '%-24s %s %s\n' "$lib" "$(git -C "$dir" remote get-url origin)" "$base" else printf '%-24s from an archive, in the source as it was unpacked\n' "$lib" fi @@ -239,11 +255,15 @@ jobs: rm -rf build/packages df -h . exclude=() - for lib in $(cut -d' ' -f1 clang_root/toolchain-sources.txt); do exclude+=("-x!src_packages/$lib"); done + for lib in $(cut -d' ' -f1 ../toolchain-sources.txt); do exclude+=("-x!src_packages/$lib"); done + # The Rust crates are in the registry, and in git checkouts only when one comes from git + paths=() + for path in clang_root/install_rustup/.cargo/registry/src clang_root/install_rustup/.cargo/git/checkouts; do + [[ -d $path ]] && paths+=("$path") + done 7z a -t7z -mx=7 -mmt=on -v1900m -xr'!.git' "${exclude[@]}" ../out/$name-source.7z \ src_packages CMakeLists.txt README.md cargo_config.in ccache_conf.in exec.in meson_cross.txt.in toolchain.cmake.in \ - cmake packages scripts toolchain clang_root/toolchain-source.7z \ - clang_root/install_rustup/.cargo/registry/src clang_root/install_rustup/.cargo/git/checkouts \ + cmake packages scripts toolchain clang_root/toolchain-source.7z "${paths[@]}" \ ../out/$name-sources.txt ls -la ../out (cd ../out && sha256sum $name-source.7z.* > $name-source.7z.sha256) From 26e8fb508bca7c96ca79a1bea7c7ea524000d354 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 03:27:33 +0530 Subject: [PATCH 40/64] libmpv workflow: leave out links that point into the deleted build tree A library's compile_commands.json links into its build folder, which is gone by the time the source is packed, and 7z fails on it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/libmpv.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml index 9f28f94118e..06c2c041153 100644 --- a/.github/workflows/libmpv.yml +++ b/.github/workflows/libmpv.yml @@ -253,6 +253,8 @@ jobs: name=${{ steps.package.outputs.name }} # The build tree is done with, and the runner's disk is small rm -rf build/packages + # Links into the build tree, such as compile_commands.json, now point at nothing + find src_packages -xtype l -print -delete df -h . exclude=() for lib in $(cut -d' ' -f1 ../toolchain-sources.txt); do exclude+=("-x!src_packages/$lib"); done From 3c23f29015a440b3c2bbf4fecc2dd783e86aeecf Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 10:42:00 +0530 Subject: [PATCH 41/64] Desktop: play video with the libmpv we built, whose source we publish libmpv-20261004-git-413ff0b1cd comes from the libmpv workflow, with its complete source and the commit of every library next to it, which the release workflow copies into each app release. The notices say where the build and its source are. Co-Authored-By: Claude Opus 5.5 (1M context) --- desktopApp/build.gradle.kts | 7 ++++--- .../common/THIRD-PARTY-NOTICES.txt | 18 +++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 2a76b44961a..7d0a61720e5 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -75,9 +75,10 @@ abstract class PrepareLibmpv : DefaultTask() { } val prepareLibmpv by tasks.registering(PrepareLibmpv::class) { - // shinchiro's 20261002 build, mirrored on the fork because shinchiro keeps only its last 30 builds - archiveUrl = "https://github.com/harsh-pockt/cloudstream/releases/download/libmpv-20261002/mpv-dev-x86_64-20261002-git-3186d369f9.7z" - archiveSha256 = "d873450cc1a7f881a8a10c33936d9555ad18a3809d827b9dbea55ba55caebcf9" + // Built by the libmpv workflow, which publishes the complete source next to it. The release workflow + // copies that source into each app release from the release named here, so keep this URL first + archiveUrl = "https://github.com/harsh-pockt/cloudstream/releases/download/libmpv-20261004-git-413ff0b1cd/mpv-dev-x86_64-20261004-git-413ff0b1cd.7z" + archiveSha256 = "356bec543ce3acd8811f10b11cddaaca9b6946226b9c8dc99e9542c61d37b672" // The archive uses the BCJ2 filter, which only 7-Zip itself unpacks. 7zr.exe is its small standalone version, // mirrored too: 7-zip.org replaces it with each new version sevenZipUrl = "https://github.com/harsh-pockt/cloudstream/releases/download/libmpv-20261002/7zr.exe" diff --git a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt index 02b8b8de92a..b71c780323f 100644 --- a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt +++ b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt @@ -12,19 +12,23 @@ zlib. This build includes GPL-licensed parts, so libmpv-2.dll as a whole is distributed under the GNU General Public License, version 2 or later, which allows distribution under version 3 (LICENSE.txt). -Build: mpv-dev-x86_64-20261002-git-3186d369f9 from -https://github.com/shinchiro/mpv-winbuild-cmake/releases/tag/20261002 -(mpv commit 3186d369f9). The build scripts and the exact versions of every -library are in that repository at the same tag. +Build: mpv-dev-x86_64-20261004-git-413ff0b1cd (mpv commit 413ff0b1cd), +built with Clang by the libmpv workflow of +https://github.com/harsh-pockt/cloudstream from the build scripts of +https://github.com/shinchiro/mpv-winbuild-cmake, and published at +https://github.com/harsh-pockt/cloudstream/releases/tag/libmpv-20261004-git-413ff0b1cd + +The complete corresponding source code of libmpv-2.dll, with the build +scripts and the exact version of every library in +mpv-dev-x86_64-20261004-git-413ff0b1cd-sources.txt, is published in that +release and with each CloudStream desktop release on GitHub, next to the +installer. Source code: mpv https://github.com/mpv-player/mpv Build scripts https://github.com/shinchiro/mpv-winbuild-cmake FFmpeg https://ffmpeg.org/download.html -The complete corresponding source code of libmpv-2.dll is also published -with each CloudStream desktop release on GitHub, next to the installer. - Android extension support ------------------------- Extensions built only for Android are converted for desktop with these From 79058bc7077d9e64cdc0fbf336be830c89dbed0e Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 11:11:03 +0530 Subject: [PATCH 42/64] Desktop: stop offering the installed beta as an update, and let betas upgrade each other The update check read 4.8.0-beta.1 as 4.8.0.0.1, newer than the 4.8.0 the installed beta reports, so it offered beta.1 again after every install. A pre-release now comes before its version, as in semantic versioning, and beta.2 after beta.1. A release build now knows its full version, pre-release included, and its MSI version ends in the release run's number: Windows only replaces an installed copy with a higher MSI version, and two betas of 4.8.0 were both 4.8.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 8 ++- desktopApp/build.gradle.kts | 11 ++-- .../com/lagradost/cloudstream4/AppData.kt | 52 +++++++++++++++---- .../com/lagradost/cloudstream4/AppDataTest.kt | 13 +++++ 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8216731a95..eb3402bdc8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,8 +60,14 @@ jobs: - name: Run tests run: ./gradlew :library:jvmTest :shared:jvmTest + # The app knows its full version, pre-release included, for the update check. The MSI version + # takes the run number as its last part: Windows only upgrades to a higher MSI version, and two + # releases of one version, such as 4.8.0-beta.1 and 4.8.0-beta.2, must still upgrade - name: Build MSI - run: ./gradlew :desktopApp:packageMsi + run: | + version=${GITHUB_REF_NAME#v} + core=${version%%-*} + ./gradlew :desktopApp:packageMsi -PappVersion=$version -PmsiVersion=${core%.*}.$GITHUB_RUN_NUMBER - name: Publish env: diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 7d0a61720e5..267052282f9 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -102,8 +102,9 @@ tasks.matching { it.name == "prepareAppResources" }.configureEach { dependsOn(as compose.desktop { application { mainClass = "com.lagradost.cloudstream4.MainKt" - // Read by AppVersion, for the update check - jvmArgs("-Dcloudstream.version=${libs.versions.versionName.get()}") + // Read by AppVersion, for the update check. The release workflow passes the tag's version, which + // can be a pre-release such as 4.8.0-beta.2 + jvmArgs("-Dcloudstream.version=${providers.gradleProperty("appVersion").getOrElse(libs.versions.versionName.get())}") // Memory. Without a limit the heap may grow to a quarter of the computer's RAM before it is // collected, while the app uses well under 100 MB of it. When idle, the heap shrinks and the // memory goes back to Windows. Strings repeated across extensions' results are kept once @@ -120,8 +121,10 @@ compose.desktop { packageName = "CloudStream" // Holds libmpv-2.dll and the notices, found at runtime through the compose.application.resources.dir property appResourcesRootDir = layout.buildDirectory.dir("app-resources") - // MSI requires MAJOR.MINOR.BUILD with numbers only, which versionName already is - packageVersion = libs.versions.versionName.get() + // MSI requires MAJOR.MINOR.BUILD with numbers only, and only replaces an installed copy with a + // higher one: two betas of 4.8.0 would both be 4.8.0. So the release workflow passes + // MAJOR.MINOR., which grows with every release + packageVersion = providers.gradleProperty("msiVersion").getOrElse(libs.versions.versionName.get()) // The packaged runtime only contains these JDK modules, regenerate with ./gradlew :desktopApp:suggestRuntimeModules // jdk.zipfs is used when converting Android extensions, which suggestRuntimeModules does not see diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt index 899dd33aad6..e8585e94567 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppData.kt @@ -150,16 +150,48 @@ object AppVersion { val current: String = System.getProperty("cloudstream.version") ?: System.getProperty("jpackage.app-version") ?: "0.0.0" - /** Compares dotted versions such as 4.10.0 and 4.9.2 number by number. A leading "v" is ignored */ - fun isNewer(candidate: String, than: String): Boolean { - fun parts(v: String) = v.trim().removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 } - val a = parts(candidate) - val b = parts(than) - for (i in 0 until maxOf(a.size, b.size)) { - val x = a.getOrElse(i) { 0 } - val y = b.getOrElse(i) { 0 } - if (x != y) return x > y + /** + * Compares versions such as 4.10.0 and 4.9.2 number by number. A leading "v" is ignored. As in + * semantic versioning, a pre-release such as 4.8.0-beta.2 comes before 4.8.0 and after 4.8.0-beta.1 + */ + fun isNewer(candidate: String, than: String): Boolean = compare(candidate, than) > 0 + + private fun compare(a: String, b: String): Int { + fun split(v: String) = v.trim().removePrefix("v").split('-', limit = 2).let { it[0] to it.getOrNull(1) } + val (coreA, preA) = split(a) + val (coreB, preB) = split(b) + val numbersA = coreA.split('.').map { it.toIntOrNull() ?: 0 } + val numbersB = coreB.split('.').map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(numbersA.size, numbersB.size)) { + val x = numbersA.getOrElse(i) { 0 } + val y = numbersB.getOrElse(i) { 0 } + if (x != y) return x.compareTo(y) + } + return when { + preA == preB -> 0 + preA == null -> 1 + preB == null -> -1 + else -> comparePreRelease(preA, preB) + } + } + + /** beta.2 after beta.1, beta.10 after beta.9, rc after beta */ + private fun comparePreRelease(a: String, b: String): Int { + val partsA = a.split('.') + val partsB = b.split('.') + for (i in 0 until maxOf(partsA.size, partsB.size)) { + val x = partsA.getOrNull(i) ?: return -1 + val y = partsB.getOrNull(i) ?: return 1 + val nx = x.toIntOrNull() + val ny = y.toIntOrNull() + val result = when { + nx != null && ny != null -> nx.compareTo(ny) + nx != null -> -1 + ny != null -> 1 + else -> x.compareTo(y) + } + if (result != 0) return result } - return false + return 0 } } diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt index 99c065f71e4..1ce696c3086 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/AppDataTest.kt @@ -106,6 +106,19 @@ class AppDataTest { assertFalse(AppVersion.isNewer("4.7.9", "4.8")) } + @Test + fun aPreReleaseComesBeforeItsVersion() { + // The installed beta.1 says 4.8.0: offering beta.1 again would install it over and over + assertFalse(AppVersion.isNewer("4.8.0-beta.1", "4.8.0")) + assertTrue(AppVersion.isNewer("4.8.0", "4.8.0-beta.1")) + assertFalse(AppVersion.isNewer("4.8.0-beta.1", "4.8.0-beta.1")) + assertTrue(AppVersion.isNewer("v4.8.0-beta.2", "4.8.0-beta.1")) + assertTrue(AppVersion.isNewer("4.8.0-beta.10", "4.8.0-beta.9")) + assertTrue(AppVersion.isNewer("4.8.0-rc.1", "4.8.0-beta.3")) + assertTrue(AppVersion.isNewer("4.8.1-beta.1", "4.8.0")) + assertFalse(AppVersion.isNewer("4.8.0-beta", "4.8.0-beta.1")) + } + @Test fun releaseIsReadFromGitHub() { val release = AppUpdater.parseRelease( From a3e0cc6b219408b5c5b0168d305b59cb6380854a Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 11:26:06 +0530 Subject: [PATCH 43/64] Release a beta of the Windows app on every merge into master A merge publishes v-beta., the beta after the last one of that version, with the changes since the last app release in its notes. A tag pushed by hand still publishes a full release. Once v itself is out, merges stop with a message to raise versionName, as their betas would come before it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb3402bdc8a..defeb9614e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,34 @@ name: Release -# Publishes the Windows app. Push a tag v, where is versionName in -# gradle/libs.versions.toml; a tag with a suffix, such as v4.8.0-beta.1, makes a pre-release. The -# release holds the installer and, as THIRD-PARTY-NOTICES.txt promises, the complete source of the +# Publishes the Windows app. +# +# - Every merge into master publishes a pre-release, v-beta., where is +# versionName in gradle/libs.versions.toml and follows the last beta of that version. +# Installed apps find it through the update check in Settings. +# - Pushing a tag v by hand publishes that version as a full release. A tag with a suffix, +# such as v4.8.0-rc.1, makes a pre-release. After a full release, raise versionName before the next +# merge: its betas would otherwise come before the release itself. +# +# The release holds the installer and, as THIRD-PARTY-NOTICES.txt promises, the complete source of the # libmpv-2.dll inside it, copied from the libmpv release the build downloads it from. on: push: + branches: [ master ] tags: [ 'v*' ] + # Path filters only apply to the branch: a tag always releases + paths-ignore: + - '*.md' + - '.github/workflows/libmpv.yml' permissions: contents: write +# One release at a time, so two merges close together never pick the same beta number +concurrency: + group: release + cancel-in-progress: false + jobs: windows: runs-on: windows-latest @@ -21,14 +38,30 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Check the tag matches the app's version + - name: Choose the version + env: + GH_TOKEN: ${{ github.token }} run: | version=$(grep -E '^versionName' gradle/libs.versions.toml | cut -d'"' -f2) - tag=${GITHUB_REF_NAME#v} - if [[ "$tag" != "$version" && "$tag" != "$version-"* ]]; then - echo "::error::Tag $GITHUB_REF_NAME does not match versionName $version in gradle/libs.versions.toml" - exit 1 + if [[ "$GITHUB_REF_TYPE" == tag ]]; then + tag=$GITHUB_REF_NAME + if [[ "${tag#v}" != "$version" && "${tag#v}" != "$version-"* ]]; then + echo "::error::Tag $tag does not match versionName $version in gradle/libs.versions.toml" + exit 1 + fi + else + if git ls-remote --exit-code --tags origin "refs/tags/v$version" > /dev/null; then + echo "::error::v$version is already released. Raise versionName in gradle/libs.versions.toml so the next betas come after it" + exit 1 + fi + last=$(git ls-remote --tags origin "refs/tags/v$version-beta.*" | sed -E 's|.*/v[^-]*-beta\.([0-9]+)$|\1|' | { grep -E '^[0-9]+$' || true; } | sort -n | tail -1) + tag=v$version-beta.$(( ${last:-0} + 1 )) fi + # The changes listed in the notes start from the last app release + previous=$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 50 --json tagName -q '.[].tagName' | grep -E '^v[0-9]' | grep -vx "$tag" | head -1 || true) + echo "Releasing $tag, changes since ${previous:-the start}" + echo "TAG=$tag" >> "$GITHUB_ENV" + echo "PREVIOUS=$previous" >> "$GITHUB_ENV" # Before the build, so a release never goes out without the source it promises - name: Get the source of the bundled libmpv @@ -65,18 +98,19 @@ jobs: # releases of one version, such as 4.8.0-beta.1 and 4.8.0-beta.2, must still upgrade - name: Build MSI run: | - version=${GITHUB_REF_NAME#v} + version=${TAG#v} core=${version%%-*} ./gradlew :desktopApp:packageMsi -PappVersion=$version -PmsiVersion=${core%.*}.$GITHUB_RUN_NUMBER + # Creates the tag on the merged commit when there is none yet - name: Publish env: GH_TOKEN: ${{ github.token }} run: | msi=$(ls desktopApp/build/compose/binaries/main/msi/*.msi) - prerelease=$([[ "$GITHUB_REF_NAME" == *-* ]] && echo --prerelease || true) + prerelease=$([[ "$TAG" == *-* ]] && echo --prerelease || true) { - echo "CloudStream for Windows ${GITHUB_REF_NAME#v}." + echo "CloudStream for Windows ${TAG#v}." echo echo "Download \`$(basename "$msi")\` and open it to install, or to update an installed version. Installed apps also find this release in Settings." echo @@ -84,5 +118,6 @@ jobs: echo echo "The \`*-source.7z.*\` files are the complete source of the libmpv-2.dll the app plays video with (GPL, see THIRD-PARTY-NOTICES.txt), with the list of every library's version in \`*-sources.txt\`." } > notes.md - gh release create "$GITHUB_REF_NAME" "$msi" libmpv-source/* --repo "$GITHUB_REPOSITORY" \ - --title "CloudStream ${GITHUB_REF_NAME#v}" --notes-file notes.md $prerelease + gh release create "$TAG" "$msi" libmpv-source/* --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + --title "CloudStream ${TAG#v}" --notes-file notes.md --generate-notes \ + ${PREVIOUS:+--notes-start-tag "$PREVIOUS"} $prerelease From 6e4a4dc9d796429ed64723a0affa005340f3e6d0 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 12:14:27 +0530 Subject: [PATCH 44/64] Desktop: look like the Android app, with its buttons, chips, dialogs and theme colours The desktop used Material's defaults where the Android app has its own styles: pill buttons in the primary colour instead of the white main button and dark other buttons, outlined chips, dialogs and outlines in Material's purple baseline. Following styles.xml: - WhiteButton and BlackButton: 40 dp, 4 dp corners, bold 15 sp text, no shadow - chips grey without an outline, the primary colour with white text once selected - dialogs on the theme's background with 10 dp corners, Cancel as a dark button - the rail on primaryGrayBackground, the selected item in the primary colour - every Material colour role taken from the theme, so no purple shows - AmoledLight and Amoled surfaces as in AmoledModeLight and AmoledMode, and the standard grey text in Dracula, Lavender and Silent Blue, as their Android themes keep it Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 14 +- .../kotlin/com/lagradost/cloudstream4/Main.kt | 8 +- .../cloudstream4/browse/DetailScreen.kt | 18 +-- .../browse/DownloadSourcePicker.kt | 8 +- .../cloudstream4/browse/DownloadsScreen.kt | 4 +- .../cloudstream4/browse/HomeScreen.kt | 10 +- .../cloudstream4/browse/LibraryScreen.kt | 4 +- .../lagradost/cloudstream4/browse/Posters.kt | 4 +- .../cloudstream4/browse/SearchScreen.kt | 4 +- .../cloudstream4/player/PlayerScreen.kt | 10 +- .../settings/AppMaintenanceSettings.kt | 21 ++- .../settings/ExtensionsSettings.kt | 22 ++-- .../cloudstream4/theme/Components.kt | 121 ++++++++++++++++++ .../lagradost/cloudstream4/theme/Palette.kt | 6 +- .../lagradost/cloudstream4/theme/Scheme.kt | 14 +- .../com/lagradost/cloudstream4/theme/Theme.kt | 45 ++++--- 16 files changed, 234 insertions(+), 79 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index c2924e020df..be9194c2b9f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -11,10 +11,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -251,10 +251,18 @@ fun AppShell(navigator: AppNavigator) { // The mouse's back button goes back, as in a browser .onPointerEvent(PointerEventType.Press) { if (it.button == PointerButton.Back) navigator.back() }, ) { + // As the Android app's nav_rail_view: on primaryGrayBackground, the selected item in the + // primary colour (item_select_color.xml). Labels stay, there is room for them on a desktop if (!hideRail) NavigationRail( modifier = Modifier.fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.background, + containerColor = MaterialTheme.colorScheme.surfaceVariant, ) { + val itemColors = NavigationRailItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onBackground, + unselectedTextColor = MaterialTheme.colorScheme.onBackground, + ) Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), @@ -271,11 +279,11 @@ fun AppShell(navigator: AppNavigator) { ) }, label = { Text(stringResource(destination.title)) }, + colors = itemColors, ) } } } - if (!hideRail) VerticalDivider() Box(Modifier.weight(1f).fillMaxHeight()) { if (page != null) { CompositionLocalProvider(LocalViewModelStoreOwner provides page) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index a08cc198342..28bb3b87695 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -5,9 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -42,7 +40,9 @@ import com.lagradost.cloudstream4.settings.AppUpdatePrompt import com.lagradost.cloudstream4.settings.autoUpdateExtensions import com.lagradost.cloudstream4.settings.desktopPrimaryColor import com.lagradost.cloudstream4.settings.desktopThemeMode +import com.lagradost.cloudstream4.theme.BlackButton import com.lagradost.cloudstream4.theme.CloudStreamTheme +import com.lagradost.cloudstream4.theme.WhiteButton import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -152,8 +152,8 @@ private fun askToStartFresh(): Boolean { modifier = Modifier.weight(1f), ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = ::exitApplication) { Text("Keep my data") } - OutlinedButton(onClick = { + WhiteButton(onClick = ::exitApplication) { Text("Keep my data") } + BlackButton(onClick = { fresh = true exitApplication() }) { Text("Start fresh") } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index a8739d81d9f..948b333e739 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -17,17 +17,14 @@ 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.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider 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 @@ -60,6 +57,9 @@ import com.lagradost.cloudstream4.generated.resources.arrow_back import com.lagradost.cloudstream4.generated.resources.play_arrow_24px import com.lagradost.cloudstream4.library.PlaybackPosition import com.lagradost.cloudstream4.library.WatchType +import com.lagradost.cloudstream4.theme.AppFilterChip +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton import org.jetbrains.compose.resources.painterResource /** The details of a movie or series, with its episodes and similar titles */ @@ -79,7 +79,7 @@ fun DetailScreen( when (val status = state.status) { DetailStatus.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } is DetailStatus.Failed -> Message("Could not load this title", status.message) { - Button(onClick = { viewModel.onAction(DetailAction.Retry) }) { Text("Try again") } + WhiteButton(onClick = { viewModel.onAction(DetailAction.Retry) }) { Text("Try again") } } DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, downloads) @@ -224,14 +224,14 @@ private fun Header( val resume = state.resume if (state.unsupported == null) when { // Continue watching left off in an episode - resume?.episode != null -> Button(onClick = { onResume(resume.episode!!) }) { + resume?.episode != null -> WhiteButton(onClick = { onResume(resume.episode!!) }) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) val at = if (resume.positionMs > 0) " at ${formatTime(resume.positionMs)}" else "" Text("${if (resume.positionMs > 0) "Resume" else "Play"} ${resume.label}$at", modifier = Modifier.padding(start = 8.dp)) } state.movieData != null -> { - Button(onClick = { onPlay(false) }) { + WhiteButton(onClick = { onPlay(false) }) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) Text(resume?.let { "Resume at ${formatTime(it.positionMs)}" } ?: "Play", modifier = Modifier.padding(start = 8.dp)) } @@ -251,7 +251,7 @@ private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { if (state.dubs.size > 1) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { state.dubs.forEach { dub -> - FilterChip( + AppFilterChip( selected = dub == state.selectedDub, onClick = { onAction(DetailAction.SelectDub(dub)) }, label = { Text(if (dub == DubStatus.None) "Default" else dub.name) }, @@ -260,7 +260,7 @@ private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { } if (state.seasons.size > 1) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { state.seasons.forEachIndexed { index, season -> - FilterChip( + AppFilterChip( selected = index == state.selectedSeason, onClick = { onAction(DetailAction.SelectSeason(index)) }, label = { Text(season.label) }, @@ -275,7 +275,7 @@ private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { private fun LibraryButton(current: WatchType, onSelect: (WatchType) -> Unit) { var open by remember { mutableStateOf(false) } Box { - OutlinedButton(onClick = { open = true }) { + BlackButton(onClick = { open = true }) { Text(if (current == WatchType.NONE) "Add to library" else current.label) } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt index 435e114022a..d1e52bd7179 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadSourcePicker.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -36,6 +35,8 @@ import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream4.download.DownloadSource import com.lagradost.cloudstream4.download.Downloads import com.lagradost.cloudstream4.providers.loadLinksSafely +import com.lagradost.cloudstream4.theme.AppShapes +import com.lagradost.cloudstream4.theme.BlackButton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -76,6 +77,9 @@ fun DownloadSourcePicker( val links by found.collectAsState() AlertDialog( + // dialog__window_background.xml + containerColor = MaterialTheme.colorScheme.background, + shape = AppShapes.dialog, onDismissRequest = onDismiss, title = { Text("Download from") }, text = { @@ -108,7 +112,7 @@ fun DownloadSourcePicker( } }, confirmButton = {}, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + dismissButton = { BlackButton(onClick = onDismiss) { Text("Cancel") } }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt index a5e9eb6bcf4..89f54f9bdff 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt @@ -22,7 +22,6 @@ 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.collectAsState @@ -53,6 +52,7 @@ import com.lagradost.cloudstream4.generated.resources.play_arrow_24px import com.lagradost.cloudstream4.library.DataStoreWatchStore import com.lagradost.cloudstream4.library.PlaybackTracking import com.lagradost.cloudstream4.library.TitleHeader +import com.lagradost.cloudstream4.theme.BlackButton import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import java.awt.Desktop @@ -82,7 +82,7 @@ fun DownloadsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - OutlinedButton(onClick = { openFolder(folder) }) { + BlackButton(onClick = { openFolder(folder) }) { Icon(painterResource(Res.drawable.folder_open_24px), contentDescription = null, modifier = Modifier.size(18.dp)) Text("Open folder", modifier = Modifier.padding(start = 8.dp)) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index d09247f702a..02f7214e618 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -11,13 +11,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -36,6 +34,8 @@ import com.lagradost.cloudstream4.home.HomeAction import com.lagradost.cloudstream4.home.HomeState import com.lagradost.cloudstream4.home.HomeStatus import com.lagradost.cloudstream4.home.HomeViewModel +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton import org.jetbrains.compose.resources.painterResource /** The home page of one provider, picked from a menu at the top */ @@ -52,14 +52,14 @@ fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit, openDetails HomeStatus.NoProviders -> Message( "No extensions with a home page", "Install an extension to browse it here. If you already have, check the extension languages and media types under Settings > Providers.", - ) { Button(onClick = openExtensions) { Text("Open Extensions") } } + ) { WhiteButton(onClick = openExtensions) { Text("Open Extensions") } } HomeStatus.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } is HomeStatus.Failed -> Message("${state.selected} could not be loaded", status.message) { - Button(onClick = { viewModel.onAction(HomeAction.Reload) }) { Text("Try again") } + WhiteButton(onClick = { viewModel.onAction(HomeAction.Reload) }) { Text("Try again") } } HomeStatus.Done -> if (state.rows.isEmpty()) { @@ -111,7 +111,7 @@ private fun TopBar(state: HomeState, onAction: (HomeAction) -> Unit) { } } Box(Modifier.weight(1f)) - OutlinedButton(onClick = { onAction(HomeAction.Reload) }, enabled = state.status != HomeStatus.Loading) { + BlackButton(onClick = { onAction(HomeAction.Reload) }, enabled = state.status != HomeStatus.Loading) { Text("Reload") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt index a66492bd9f7..a121f0af39c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilterChip import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -38,6 +37,7 @@ import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.library.TitleHeader import com.lagradost.cloudstream4.library.WatchStore import com.lagradost.cloudstream4.library.WatchType +import com.lagradost.cloudstream4.theme.AppFilterChip /** The titles the user put in a library list, one list at a time, most recently changed first */ @OptIn(ExperimentalLayoutApi::class) @@ -54,7 +54,7 @@ fun LibraryScreen(store: WatchStore, openDetails: (apiName: String, url: String) FlowRow(Modifier.padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { WatchType.lists.forEach { type -> val count = bookmarks.count { it.status == type } - FilterChip( + AppFilterChip( selected = type == selected, onClick = { selected = type }, label = { Text(if (count > 0) "${type.label} · $count" else type.label) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt index 2601784ee2f..e43633390a9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -45,6 +44,7 @@ import coil3.serviceLoaderEnabled import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream4.AppDirs +import com.lagradost.cloudstream4.theme.BlackButton import okio.Path.Companion.toOkioPath /** @@ -165,7 +165,7 @@ fun MoreButton(loading: Boolean, horizontal: Boolean, onClick: () -> Unit) { contentAlignment = Alignment.Center, ) { if (loading) CircularProgressIndicator(Modifier.size(24.dp)) - else OutlinedButton(onClick = onClick) { Text("More") } + else BlackButton(onClick = onClick) { Text("More") } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt index e2678c51b49..9b97f55037e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -42,6 +41,7 @@ import com.lagradost.cloudstream4.search.ProviderStatus import com.lagradost.cloudstream4.search.SearchAction import com.lagradost.cloudstream4.search.SearchHistoryEntry import com.lagradost.cloudstream4.search.SearchViewModel +import com.lagradost.cloudstream4.theme.WhiteButton import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -90,7 +90,7 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDet state.noProviders -> Message( "No extensions to search with", "Install an extension, or check the extension languages and media types under Settings > Providers.", - ) { Button(onClick = openExtensions) { Text("Open Extensions") } } + ) { WhiteButton(onClick = openExtensions) { Text("Open Extensions") } } state.searchedQuery.isEmpty() && state.history.isNotEmpty() -> SearchHistory( entries = state.history, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 9cc23b8b844..734da920756 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -23,9 +23,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.Button import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -87,6 +85,8 @@ import com.lagradost.cloudstream4.generated.resources.pause_24px import com.lagradost.cloudstream4.generated.resources.play_arrow_24px import com.lagradost.cloudstream4.library.DataStoreWatchStore import com.lagradost.cloudstream4.library.saveProgress +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.lagradost.cloudstream3.utils.ExtractorLink @@ -459,8 +459,8 @@ private fun TorrentPrompt(onPlay: () -> Unit, onSkip: () -> Unit) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text("Play torrent") } - OutlinedButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { Text("Use another source") } + WhiteButton(onClick = onPlay, modifier = Modifier.fillMaxWidth()) { Text("Play torrent") } + BlackButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { Text("Use another source") } } } @@ -487,7 +487,7 @@ private fun CheckPrompt(sites: List, checking: Boolean, onOpen: () -> Un style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button(onClick = onOpen, enabled = !checking, modifier = Modifier.fillMaxWidth()) { + WhiteButton(onClick = onOpen, enabled = !checking, modifier = Modifier.fillMaxWidth()) { Text(if (checking) "Waiting for the check…" else "Open browser") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt index ea755a83187..fa49bf47bbf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt @@ -6,10 +6,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button 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 @@ -28,6 +26,9 @@ import com.lagradost.cloudstream4.AppDirs import com.lagradost.cloudstream4.AppVersion import com.lagradost.cloudstream4.desktopPreferences import com.lagradost.cloudstream4.generated.resources.* +import com.lagradost.cloudstream4.theme.AppShapes +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton import com.lagradost.cloudstream4.update.AppRelease import com.lagradost.cloudstream4.update.AppUpdater import com.mihon.presentation.settings.Preference @@ -82,8 +83,8 @@ private fun UpdateActions(release: AppRelease, state: UpdateState, onLater: (() } state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } if (state.progress == null) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { scope.launch { state.install(release) } }) { Text("Update now") } - OutlinedButton(onClick = { runCatching { uriHandler.openUri(release.pageUrl) } }) { Text("What's new") } + WhiteButton(onClick = { scope.launch { state.install(release) } }) { Text("Update now") } + BlackButton(onClick = { runCatching { uriHandler.openUri(release.pageUrl) } }) { Text("What's new") } onLater?.let { TextButton(onClick = it) { Text("Later") } } } } @@ -102,6 +103,9 @@ fun AppUpdatePrompt() { } val current = release ?: return AlertDialog( + // dialog__window_background.xml + containerColor = MaterialTheme.colorScheme.background, + shape = AppShapes.dialog, onDismissRequest = { if (state.progress == null) release = null }, title = { Text("CloudStream ${current.version} is available") }, text = { @@ -156,7 +160,7 @@ object SettingsUpdatesScreen : SearchableSettings { modifier = Modifier.weight(1f), ) if ((check as? Check.Done)?.release == null) { - OutlinedButton(enabled = check != Check.Checking, onClick = ::checkNow) { Text("Check now") } + BlackButton(enabled = check != Check.Checking, onClick = ::checkNow) { Text("Check now") } } } (check as? Check.Done)?.release?.let { UpdateActions(it, state) } @@ -194,6 +198,9 @@ object SettingsStorageScreen : SearchableSettings { confirm?.let { reset -> AlertDialog( + // dialog__window_background.xml + containerColor = MaterialTheme.colorScheme.background, + shape = AppShapes.dialog, onDismissRequest = { confirm = null }, title = { Text("${reset.title}?") }, text = { @@ -206,12 +213,12 @@ object SettingsStorageScreen : SearchableSettings { ) }, confirmButton = { - Button(onClick = { + WhiteButton(onClick = { AppData.instance.scheduleReset(reset) restartApp() }) { Text(reset.title) } }, - dismissButton = { TextButton(onClick = { confirm = null }) { Text("Cancel") } }, + dismissButton = { BlackButton(onClick = { confirm = null }) { Text("Cancel") } }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt index 5d8cf03460e..f5e3f8715a8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/ExtensionsSettings.kt @@ -6,10 +6,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -36,6 +34,8 @@ import com.lagradost.cloudstream4.plugins.PluginUpdate import com.lagradost.cloudstream4.plugins.RepoPlugin import com.lagradost.cloudstream4.plugins.Repository import com.lagradost.cloudstream4.plugins.RepositoryClient +import com.lagradost.cloudstream4.theme.BlackButton +import com.lagradost.cloudstream4.theme.WhiteButton import com.mihon.common.preference.minusAssign import com.mihon.common.preference.plusAssign import com.mihon.presentation.settings.Preference @@ -257,7 +257,7 @@ object AddRepositoryScreen : SearchableSettings { label = { Text("Repository URL or short code, for example megarepo") }, ) BusyOr(adding) { - Button( + WhiteButton( enabled = text.isNotBlank(), onClick = { val input = text @@ -353,7 +353,7 @@ object InstalledExtensionsScreen : SearchableSettings { ) BusyOr(current is Check.Checking || busy["all"] == true, label = "Updating".takeIf { busy["all"] == true }) { if (updates.isNotEmpty()) { - Button(onClick = { + WhiteButton(onClick = { busy["all"] = true scope.launch { val failed = manager.installUpdates(updates) @@ -362,7 +362,7 @@ object InstalledExtensionsScreen : SearchableSettings { } }) { Text("Update all") } } else { - OutlinedButton(enabled = installed.isNotEmpty(), onClick = ::checkForUpdates) { Text("Check now") } + BlackButton(enabled = installed.isNotEmpty(), onClick = ::checkForUpdates) { Text("Check now") } } } } @@ -404,8 +404,8 @@ object InstalledExtensionsScreen : SearchableSettings { } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (update != null) Button(onClick = { run { manager.install(plugin.repositoryUrl, update.available) } }) { Text("Update") } - OutlinedButton(onClick = { run { manager.uninstall(plugin.internalName) } }) { Text("Uninstall") } + if (update != null) WhiteButton(onClick = { run { manager.install(plugin.repositoryUrl, update.available) } }) { Text("Update") } + BlackButton(onClick = { run { manager.uninstall(plugin.internalName) } }) { Text("Uninstall") } } } }, @@ -500,15 +500,15 @@ class RepositoryScreen(private val url: String) : SearchableSettings { } } when { - installedVersion == null -> Button(onClick = { + installedVersion == null -> WhiteButton(onClick = { run { manager.install(url, plugin) } }) { Text("Install") } - installedVersion < plugin.version -> Button(onClick = { + installedVersion < plugin.version -> WhiteButton(onClick = { run { manager.install(url, plugin) } }) { Text("Update") } - else -> OutlinedButton(onClick = { + else -> BlackButton(onClick = { run { manager.uninstall(plugin.internalName) } }) { Text("Uninstall") } } @@ -564,7 +564,7 @@ private fun megaRepoGroup(scope: CoroutineScope): Preference.PreferenceGroup { Preference.PreferenceItem.CustomPreference("Add all community repositories") { Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { BusyOr(adding) { - Button(onClick = { + WhiteButton(onClick = { adding = true scope.launch { result = runCatching { "Added ${addCommunityRepositories()} community repositories" } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt new file mode 100644 index 00000000000..d87c4d50d51 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt @@ -0,0 +1,121 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The Android app's buttons and chips, from styles.xml. Material's own buttons are pills in the + * primary colour; the app's are square-cornered, white for the main action and dark for the others. + */ +object AppShapes { + /** rounded_button_radius in dimens.xml */ + val button = RoundedCornerShape(4.dp) + + /** dialog__window_background.xml */ + val dialog = RoundedCornerShape(10.dp) +} + +/** WhiteButton in styles.xml: the main action, such as Play */ +@Composable +fun WhiteButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) = NiceButton( + onClick, modifier, enabled, contentPadding, + container = MaterialTheme.colorScheme.onBackground, + // iconGrayBackground + contentColor = MaterialTheme.colorScheme.surface, + content = content, +) + +/** BlackButton in styles.xml: every other action, such as Download */ +@Composable +fun BlackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit, +) = NiceButton( + onClick, modifier, enabled, contentPadding, + // iconGrayBackground + container = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onBackground, + content = content, +) + +/** NiceButton in styles.xml: 40 dp high, no shadow, bold 15 sp text */ +@Composable +private fun NiceButton( + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + contentPadding: PaddingValues, + container: Color, + contentColor: Color, + content: @Composable RowScope.() -> Unit, +) = Button( + onClick = onClick, + modifier = modifier.defaultMinSize(minHeight = 40.dp), + enabled = enabled, + shape = AppShapes.button, + colors = ButtonDefaults.buttonColors( + containerColor = container, + contentColor = contentColor, + disabledContainerColor = container.copy(alpha = 0.38f), + disabledContentColor = contentColor.copy(alpha = 0.6f), + ), + elevation = null, + contentPadding = contentPadding, +) { + ProvideTextStyle(MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp)) { + content() + } +} + +/** ChipFilled in styles.xml: grey without an outline, and the primary colour once selected */ +@Composable +fun AppFilterChip( + selected: Boolean, + onClick: () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val colors = MaterialTheme.colorScheme + FilterChip( + selected = selected, + onClick = onClick, + label = label, + modifier = modifier, + enabled = enabled, + border = null, + colors = FilterChipDefaults.filterChipColors( + // primaryGrayBackground + containerColor = colors.surfaceVariant, + labelColor = colors.onBackground, + iconColor = colors.onBackground, + selectedContainerColor = colors.primary, + selectedLabelColor = colors.onPrimary, + selectedLeadingIconColor = colors.onPrimary, + selectedTrailingIconColor = colors.onPrimary, + ), + ) +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt index c6332808f02..db15b2018b2 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt @@ -44,7 +44,8 @@ internal object CloudStreamPalette { // Amoled val AmoledBlack = Color(0xFF000000) - val AmoledNearBlack = Color(0xFF121212) + /** amoledModeLight in colors.xml */ + val AmoledNearBlack = Color(0xFF121213) // Light val LightPrimaryGrayBg = Color(0xFFF1F1F1) @@ -61,7 +62,6 @@ internal object CloudStreamPalette { val DraculaIconGrayBg = Color(0xFF44475A) val DraculaBoxItemBg = Color(0xFF373844) val DraculaText = Color(0xFFF8F8F2) - val DraculaGrayText = Color(0xFF6272A4) val DraculaIcon = Color(0xFF6272A4) // Lavender Dreams @@ -70,7 +70,6 @@ internal object CloudStreamPalette { val LavenderIconGrayBg = Color(0xFFB794F6) val LavenderBoxItemBg = Color(0xFFF8F5FF) val LavenderText = Color(0xFF2D1B47) - val LavenderGrayText = Color(0xFF9AB3FF) val LavenderIcon = Color(0xFF7C3AED) // Silent Blue @@ -79,6 +78,5 @@ internal object CloudStreamPalette { val SilentBlueIconGrayBg = Color(0xFF3A446A) val SilentBlueBoxItemBg = Color(0xFF3A446A) val SilentBlueText = Color(0xFFE0E1F3) - val SilentBlueGrayText = Color(0xFF7B83B0) val SilentBlueIcon = Color(0xFF7B83B0) } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt index d51c75d0822..20ddbba747e 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt @@ -98,15 +98,18 @@ internal fun darkScheme() = CloudStreamColorScheme( ) +/** AmoledMode in styles.xml: black, with iconGrayBackground left at primaryBlackBackground */ internal fun amoledScheme() = darkScheme().copy( background = CloudStreamPalette.AmoledBlack, - surface = CloudStreamPalette.AmoledBlack, + surface = CloudStreamPalette.DarkBlackBg, surfaceVariant = CloudStreamPalette.AmoledBlack, surfaceContainer = CloudStreamPalette.AmoledBlack, ) +/** AmoledModeLight in styles.xml, the Android app's default */ internal fun amoledLightScheme() = amoledScheme().copy( surfaceVariant = CloudStreamPalette.AmoledNearBlack, + surfaceContainer = CloudStreamPalette.AmoledNearBlack, ) internal fun lightScheme() = CloudStreamColorScheme( @@ -128,7 +131,8 @@ internal fun draculaScheme() = CloudStreamColorScheme( surface = CloudStreamPalette.DraculaIconGrayBg, surfaceContainer = CloudStreamPalette.DraculaBoxItemBg, onBackground = CloudStreamPalette.DraculaText, - onSurfaceVariant = CloudStreamPalette.DraculaGrayText, + // The Android theme keeps the standard grayTextColor + onSurfaceVariant = CloudStreamPalette.DarkGrayText, icon = CloudStreamPalette.DraculaIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, @@ -141,7 +145,8 @@ internal fun lavenderScheme() = CloudStreamColorScheme( surface = CloudStreamPalette.LavenderIconGrayBg, surfaceContainer = CloudStreamPalette.LavenderBoxItemBg, onBackground = CloudStreamPalette.LavenderText, - onSurfaceVariant = CloudStreamPalette.LavenderGrayText, + // The Android theme keeps the standard grayTextColor + onSurfaceVariant = CloudStreamPalette.DarkGrayText, icon = CloudStreamPalette.LavenderIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, @@ -154,7 +159,8 @@ internal fun silentBlueScheme() = CloudStreamColorScheme( surface = CloudStreamPalette.SilentBlueIconGrayBg, surfaceContainer = CloudStreamPalette.SilentBlueBoxItemBg, onBackground = CloudStreamPalette.SilentBlueText, - onSurfaceVariant = CloudStreamPalette.SilentBlueGrayText, + // The Android theme keeps the standard grayTextColor + onSurfaceVariant = CloudStreamPalette.DarkGrayText, icon = CloudStreamPalette.SilentBlueIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt index 196d8d6a1af..e8ff2d235f7 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream4.theme import androidx.compose.animation.core.InfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -73,29 +74,39 @@ fun modeToTheme(mode : CloudStreamThemeMode, primaryColor: CloudStreamPrimaryCol return color } -private fun CloudStreamColorScheme.toMaterial3ColorScheme() = if (isLight) { - lightColorScheme( +/** + * Every Material role comes from the theme's own colours, as in the Android app's styles.xml. + * A role left out would keep Material's purple baseline, which shows in chips, dialogs, outlines + * and tonal surfaces. + */ +private fun CloudStreamColorScheme.toMaterial3ColorScheme(): ColorScheme { + val base = if (isLight) lightColorScheme() else darkColorScheme() + return base.copy( primary = primary, - background = background, - surface = surface, - surfaceVariant = surfaceVariant, - surfaceContainer = surfaceContainer, - onBackground = onBackground, - onSurface = onBackground, - onSurfaceVariant = onSurfaceVariant, onPrimary = Color.White, - ) -} else { - darkColorScheme( - primary = primary, + primaryContainer = primary, + onPrimaryContainer = Color.White, + inversePrimary = primary, + secondary = primary, + onSecondary = Color.White, + tertiary = primary, + onTertiary = Color.White, background = background, - surface = surface, - surfaceVariant = surfaceVariant, - surfaceContainer = surfaceContainer, onBackground = onBackground, + surface = surface, onSurface = onBackground, + surfaceVariant = surfaceVariant, onSurfaceVariant = onSurfaceVariant, - onPrimary = Color.White, + surfaceTint = primary, + surfaceDim = background, + surfaceBright = surfaceVariant, + surfaceContainerLowest = background, + surfaceContainerLow = surface, + surfaceContainer = surfaceContainer, + surfaceContainerHigh = surfaceVariant, + surfaceContainerHighest = surfaceVariant, + outline = onSurfaceVariant, + outlineVariant = surfaceVariant, ) } From a55bd9ea88afd5015bf33be34d51a9f2ec580891 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 14:10:10 +0530 Subject: [PATCH 45/64] Desktop player: the picture keeps its size when the bars or a panel come and go In full screen every action brought the bars back, and the video shrank to make room for them, then grew again when they hid: the picture jumped on every key, seek or mouse move. Opening a panel squeezed it the same way, in a window too. The video is a native window that nothing can be drawn over, so it still gives way to the bars and panels, and mpv now keeps the picture where it was in the whole area (video-zoom and video-pan from the full and visible areas): the bars and panels cover its edge, as an overlay would. Popups in windows of their own over the video were tried first: Skia crashes drawing them on entering full screen. Full screen also left the window laid out with its old frame when it had been maximized, as its size does not change: the content sat 31 px off, with grey strips at the edges. The window is laid out again once Windows has taken the frame away or put it back. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../kotlin/com/lagradost/cloudstream4/Main.kt | 7 + .../com/lagradost/cloudstream4/player/Mpv.kt | 35 +++++ .../cloudstream4/player/PlayerScreen.kt | 139 ++++++++++++------ 3 files changed, 135 insertions(+), 46 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 28bb3b87695..01aabebf62f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -46,6 +46,7 @@ import com.lagradost.cloudstream4.theme.WhiteButton import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -176,6 +177,12 @@ private fun FullscreenEffect(navigator: AppNavigator, windowState: WindowState, LaunchedEffect(navigator.fullscreen) { if (WindowsFullscreen.supported) { if (navigator.fullscreen) WindowsFullscreen.enter(window) else WindowsFullscreen.exit(window) + // The frame comes and goes without the window's size changing when it was maximized, so + // nothing lays it out again: the content kept the frame's place, and the video moved apart + // from the bars drawn around it. Once Windows has taken the frame away or put it back + var waited = 0 + while ((window.insets.top == 0) != navigator.fullscreen && waited++ < 20) delay(25) + window.revalidate() } else if (navigator.fullscreen) { before = windowState.placement windowState.placement = WindowPlacement.Fullscreen diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 6dc68bd141b..c0850d67f65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -1,5 +1,8 @@ package com.lagradost.cloudstream4.player +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.utils.ExtractorLink import com.sun.jna.Library @@ -17,6 +20,7 @@ import java.awt.event.MouseWheelEvent import java.io.File import java.util.concurrent.TimeUnit import java.util.concurrent.CountDownLatch +import kotlin.math.ln /** The libmpv functions the player uses, see mpv/client.h */ @Suppress("FunctionName") @@ -131,6 +135,9 @@ data class MpvStatus( val speed: Double = 1.0, /** Seconds the subtitles are shown later, negative for earlier */ val subDelay: Double = 0.0, + /** The video's size as shown, its aspect ratio applied, 0 before it is known */ + val videoWidth: Int = 0, + val videoHeight: Int = 0, ) /** A subtitle file to show with a link, added again whenever the link changes */ @@ -384,6 +391,32 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun setSpeed(speed: Double) = whenStarted { command("set", "speed", speed.toString()) } + /** + * Keeps the picture where it would be in [full] while only [visible], a part of it, shows the + * video: what the app puts beside the video then covers the picture's edge instead of shrinking it. + * Both in pixels, [visible] relative to [full]. The video's size is needed, from [status]. + */ + fun keepPicture(full: IntSize, visible: IntRect, videoWidth: Int, videoHeight: Int) = whenStarted { + if (videoWidth <= 0 || videoHeight <= 0 || visible.width <= 0 || visible.height <= 0 || + full.width <= 0 || full.height <= 0 || visible == IntRect(IntOffset.Zero, full) + ) { + command("set", "video-zoom", "0") + command("set", "video-pan-x", "0") + command("set", "video-pan-y", "0") + return@whenStarted + } + // The scale mpv would fit the video to in each, and the zoom from the visible part's to the full one's + val fullScale = minOf(full.width.toDouble() / videoWidth, full.height.toDouble() / videoHeight) + val visibleScale = minOf(visible.width.toDouble() / videoWidth, visible.height.toDouble() / videoHeight) + // The pan moves the centre of the visible part back to the centre of the full area, in parts + // of the scaled video's size + val dx = full.width / 2.0 - visible.center.x + val dy = full.height / 2.0 - visible.center.y + command("set", "video-zoom", (ln(fullScale / visibleScale) / ln(2.0)).toString()) + command("set", "video-pan-x", (dx / (videoWidth * fullScale)).toString()) + command("set", "video-pan-y", (dy / (videoHeight * fullScale)).toString()) + } + /** How subtitles look, from SubtitleStyle.mpvOptions. The one shown changes at once */ fun setSubtitleStyle(options: List>) = whenStarted { options.forEach { (name, value) -> command("set", name, value) } @@ -435,6 +468,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { muted = property("mute") == "yes", speed = property("speed")?.toDoubleOrNull() ?: 1.0, subDelay = property("sub-delay")?.toDoubleOrNull() ?: 0.0, + videoWidth = property("dwidth")?.toIntOrNull() ?: 0, + videoHeight = property("dheight")?.toIntOrNull() ?: 0, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 734da920756..74755f46af2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -66,6 +66,9 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.platform.LocalDensity import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream4.browse.Message @@ -362,14 +365,10 @@ fun PlayerScreen( } // Clicks on the bars outside their buttons close an open panel val closePanel = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { panel = null } - Column( - Modifier - .fillMaxSize() - .background(Color.Black) - // Moves over the bars, which the video does not see - .onPointerEvent(PointerEventType.Move) { pointerMoved() }, - ) { - if (showBars) TopBar( + // Moves over the bars, which the video does not see + val barMoves = Modifier.onPointerEvent(PointerEventType.Move) { pointerMoved() } + val topBar: @Composable (Modifier) -> Unit = { modifier -> + TopBar( title = listOfNotNull(viewModel.request.title, viewModel.request.episodeLabel).joinToString(" · "), line = subtitleMessage ?: when { state.exhausted && state.checkSites.isNotEmpty() -> @@ -386,44 +385,11 @@ fun PlayerScreen( onRetry = if (state.exhausted) ({ viewModel.onAction(LinksAction.Retry) }) else null, // Some extensions keep finding links for minutes: what is found so far can be enough onSkip = if (state.loading && state.links.isNotEmpty()) ({ viewModel.onAction(LinksAction.Skip) }) else null, - modifier = closePanel, + modifier = modifier.then(closePanel).then(barMoves), ) - // The video is a native window, so nothing can be drawn over it: panels open beside it - Row(Modifier.fillMaxWidth().weight(1f)) { - SwingPanel(factory = { player.canvas }, modifier = Modifier.weight(1f).fillMaxHeight(), background = Color.Black) - if (state.exhausted && state.checkSites.isNotEmpty()) CheckPrompt( - sites = state.checkSites, - checking = state.checking, - onOpen = { viewModel.onAction(LinksAction.PassChecks) }, - ) - if (askTorrent) TorrentPrompt( - onPlay = { - TorrServer.accepted = true - torrentAccepted = true - }, - onSkip = { selected?.let { viewModel.onAction(LinksAction.Failed(it.url, "Torrent not played")) } }, - ) - val open = panel - if (open != null && showBars) SidePanel( - panel = open, - state = state, - status = status, - downloaded = downloaded, - loadingSubtitle = loadingSubtitle, - onSelect = { viewModel.onAction(LinksAction.Select(it)) }, - onSubtitle = ::showSubtitle, - onSubtitleTrack = { id -> - subtitleChoice = if (id == null) SubtitleChoice.Off else SubtitleChoice.Default - player.selectSubtitle(id) - }, - onSubDelay = { if (it == 0.0) player.resetSubDelay() else player.changeSubDelay(it) }, - textSize = subtitleStyle.textSize, - onTextSize = { size -> subtitleSettings.setStyle(subtitleSettings.style.value.copy(fixedTextSize = size)) }, - onAudioTrack = player::selectAudio, - onSpeed = player::setSpeed, - ) - } - if (showBars) ControlBar( + } + val controlBar: @Composable (Modifier) -> Unit = { modifier -> + ControlBar( player = player, status = status, state = state, @@ -432,9 +398,90 @@ fun PlayerScreen( fullscreen = fullscreen, onPanel = { panel = it }, onToggleFullscreen = onToggleFullscreen, - modifier = closePanel, + modifier = modifier.then(closePanel).then(barMoves), ) } + // The picture keeps its size and place whatever opens around it. In a window the bars sit above and + // below the video and stay. In full screen the video fills the screen, and the bars that come and + // go cover its edges, as do the panels in both. The video is a native window that nothing can be + // drawn over, so it gives way to them, and mpv keeps the picture where it was: see keepPicture + var area by remember { mutableStateOf(IntSize.Zero) } + var topBarHeight by remember { mutableStateOf(0) } + var controlBarHeight by remember { mutableStateOf(0) } + var sideWidth by remember { mutableStateOf(0) } + val overlaid = fullscreen && showBars + val open = panel?.takeIf { showBars } + val prompt = state.exhausted && state.checkSites.isNotEmpty() + val side = open != null || prompt || askTorrent + val visible = IntRect( + left = 0, + top = if (overlaid) topBarHeight else 0, + right = area.width - if (side) sideWidth else 0, + bottom = area.height - if (overlaid) controlBarHeight else 0, + ) + LaunchedEffect(area, visible, status.videoWidth, status.videoHeight) { + player.keepPicture(area, visible, status.videoWidth, status.videoHeight) + } + val density = LocalDensity.current + fun Int.dp() = with(density) { toDp() } + Column( + Modifier + .fillMaxSize() + .background(Color.Black) + .then(barMoves), + ) { + if (!fullscreen) topBar(Modifier) + Box(Modifier.fillMaxWidth().weight(1f).onSizeChanged { area = it }) { + SwingPanel( + factory = { player.canvas }, + modifier = Modifier + .padding(top = visible.top.dp(), end = (area.width - visible.right).dp(), bottom = (area.height - visible.bottom).dp()) + .fillMaxSize(), + background = Color.Black, + ) + if (overlaid) topBar(Modifier.align(Alignment.TopCenter).onSizeChanged { topBarHeight = it.height }) + if (overlaid) controlBar(Modifier.align(Alignment.BottomCenter).onSizeChanged { controlBarHeight = it.height }) + if (side) Row( + Modifier + .align(Alignment.TopEnd) + .padding(top = visible.top.dp(), bottom = (area.height - visible.bottom).dp()) + .fillMaxHeight() + .onSizeChanged { sideWidth = it.width }, + ) { + if (prompt) CheckPrompt( + sites = state.checkSites, + checking = state.checking, + onOpen = { viewModel.onAction(LinksAction.PassChecks) }, + ) + if (askTorrent) TorrentPrompt( + onPlay = { + TorrServer.accepted = true + torrentAccepted = true + }, + onSkip = { selected?.let { viewModel.onAction(LinksAction.Failed(it.url, "Torrent not played")) } }, + ) + if (open != null) SidePanel( + panel = open, + state = state, + status = status, + downloaded = downloaded, + loadingSubtitle = loadingSubtitle, + onSelect = { viewModel.onAction(LinksAction.Select(it)) }, + onSubtitle = ::showSubtitle, + onSubtitleTrack = { id -> + subtitleChoice = if (id == null) SubtitleChoice.Off else SubtitleChoice.Default + player.selectSubtitle(id) + }, + onSubDelay = { if (it == 0.0) player.resetSubDelay() else player.changeSubDelay(it) }, + textSize = subtitleStyle.textSize, + onTextSize = { size -> subtitleSettings.setStyle(subtitleSettings.style.value.copy(fixedTextSize = size)) }, + onAudioTrack = player::selectAudio, + onSpeed = player::setSpeed, + ) + } + } + if (!fullscreen) controlBar(Modifier) + } } /** From ed453ef0d46c15dd0ff817f886172233290fc402 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 14:59:39 +0530 Subject: [PATCH 46/64] Desktop player: the wheel sets volume on the right and brightness on the left, as on Android A touchpad's small turns have 0 whole notches, which the wheel took as turns down: scrolling up on a touchpad lowered the volume. The window also passed each turn on to the video again a few times, so one notch moved the volume 30 %. Turns now count by their precise rotation, 5 % a notch, and stop at the video. As the Android player's swipes, the right half of the video sets the volume and the left half the brightness of the picture. A level shows on the other side while it changes: a thin bar with an icon and the percent, drawn by mpv over the video. The arrow keys show the volume's. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 30 ++++-- .../cloudstream4/player/PlayerScreen.kt | 93 ++++++++++++++++++- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 6dc68bd141b..70985a1ce0a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -144,6 +144,11 @@ sealed interface MpvEvent { /** The mouse's back button inside the video */ data object Back : MpvEvent data object Ended : MpvEvent + /** + * The mouse wheel or a touchpad turned over the video, at [x] across it from 0 to 1, by [notches] + * of a wheel, up positive + */ + data class Wheel(val x: Float, val notches: Double) : MpvEvent /** The file could not be played, for example a refused or broken link */ data class Failed(val url: String, val message: String) : MpvEvent } @@ -216,8 +221,13 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { } override fun mouseWheelMoved(e: MouseWheelEvent) { - // The wheel changes the volume, as in most desktop players - changeVolume(if (e.wheelRotation < 0) 5 else -5) + // Handled here: otherwise the window passes the same turn on to the video again, a few + // times over, and one notch would move the level several steps + e.consume() + // A touchpad sends many small turns, where the whole notches in wheelRotation are 0: + // only the precise rotation says how far, and which way + val notches = -e.preciseWheelRotation + if (notches != 0.0) _events.trySend(MpvEvent.Wheel((e.x.toFloat() / width.coerceAtLeast(1)).coerceIn(0f, 1f), notches)) } } addMouseListener(mouse) @@ -398,11 +408,18 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { /** * Draws [ass] over the video, in ASS on a 1280 by 720 canvas, or removes it when null. The - * video is a native window, so this is the only way to show something on top of it. + * video is a native window, so this is the only way to show something on top of it. Each + * [layer] is drawn and removed on its own */ - fun overlay(ass: String?) = whenStarted { - if (ass == null) command("osd-overlay", OVERLAY_ID, "none", "") - else command("osd-overlay", OVERLAY_ID, "ass-events", ass, "1280", "720") + fun overlay(ass: String?, layer: Int = 1) = whenStarted { + if (ass == null) command("osd-overlay", layer.toString(), "none", "") + else command("osd-overlay", layer.toString(), "ass-events", ass, "1280", "720") + } + + /** The picture's brightness, from 0 for dark to 100 as the video is */ + fun setBrightness(percent: Int) = whenStarted { + // mpv's -100 is black: the darkest step still shows the picture, like a screen at its lowest + command("set", "brightness", ((percent.coerceIn(0, 100) - 100) * 0.8).toInt().toString()) } fun status(): MpvStatus { @@ -443,7 +460,6 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { const val MPV_EVENT_END_FILE = 7 const val MPV_EVENT_FILE_LOADED = 8 const val MPV_EVENT_PLAYBACK_RESTART = 21 - const val OVERLAY_ID = "1" const val END_FILE_REASON_EOF = 0 const val END_FILE_REASON_ERROR = 4 } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 734da920756..360a9d5e7b5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -231,9 +231,49 @@ fun PlayerScreen( } } val showBars = !fullscreen || barsShown + // The mouse wheel over the video works as swipes do in the Android player: on the right half it + // sets the volume, on the left half the brightness, each shown by a level on the other side + var level by remember { mutableStateOf(null) } + var brightness by remember { mutableStateOf(100) } + val wheelRest = remember { doubleArrayOf(0.0) } + fun changeLevel(kind: LevelKind, delta: Int) { + // The level shown is newer than the status, which is read twice a second + val now = level?.takeIf { it.kind == kind }?.percent ?: if (kind == LevelKind.Volume) status.volume else brightness + val next = (now + delta).coerceIn(0, 100) + when (kind) { + LevelKind.Volume -> { + if (status.muted && delta > 0) player.toggleMute() + player.setVolume(next) + } + LevelKind.Brightness -> { + brightness = next + player.setBrightness(next) + } + } + level = Level(kind, next, System.nanoTime()) + } + LaunchedEffect(level) { + val shown = level + if (shown == null) { + player.overlay(null, LEVEL_LAYER) + return@LaunchedEffect + } + player.overlay(levelAss(shown), LEVEL_LAYER) + delay(1500) + level = null + } LaunchedEffect(player) { player.events.collect { event -> when (event) { + // 5 % a notch of the wheel, and a touchpad's small turns add up to it + is MpvEvent.Wheel -> { + wheelRest[0] += event.notches * 5 + val steps = wheelRest[0].toInt() + if (steps != 0) { + wheelRest[0] -= steps + changeLevel(if (event.x >= 0.5f) LevelKind.Volume else LevelKind.Brightness, steps) + } + } MpvEvent.ToggleFullscreen -> currentFullscreen() MpvEvent.Back -> currentBack() MpvEvent.Click -> if (panel != null) panel = null else player.togglePause() @@ -341,8 +381,8 @@ fun PlayerScreen( Key.Spacebar, Key.K -> player.togglePause() Key.DirectionLeft, Key.J -> player.seek(-10.0) Key.DirectionRight, Key.L -> player.seek(10.0) - Key.DirectionUp -> player.changeVolume(5) - Key.DirectionDown -> player.changeVolume(-5) + Key.DirectionUp -> changeLevel(LevelKind.Volume, 5) + Key.DirectionDown -> changeLevel(LevelKind.Volume, -5) // The app hides at once, and the video must not carry on behind it: the window handles M next Key.M -> { player.pause() @@ -558,6 +598,55 @@ private fun spinnerAss(step: Int, text: String): String { return lines.joinToString("\n") } +private enum class LevelKind { Volume, Brightness } + +/** A level set with the wheel or the keys, [at] telling apart two changes to the same percent */ +private data class Level(val kind: LevelKind, val percent: Int, val at: Long) + +/** The overlay layer of the levels, above the spinner's */ +private const val LEVEL_LAYER = 2 + +/** + * A level as the Android player shows it, drawn by mpv over the video: a thin bar filled from the + * bottom, an icon above and the percent below. The volume's on the left, the brightness's on the + * right, away from the hand that sets it. + */ +private fun levelAss(level: Level): String { + fun n(v: Double) = "%.1f".format(java.util.Locale.ROOT, v) + fun rect(w: Double, h: Double) = "m 0 0 l ${n(w)} 0 ${n(w)} ${n(h)} 0 ${n(h)}" + val volume = level.kind == LevelKind.Volume + val cx = if (volume) 110.0 else 1170.0 + val top = 260.0 + val height = 200.0 + val width = 6.0 + val edge = "\\bord1\\shad0\\3c&H000000&\\3a&H90&" + val lines = mutableListOf() + // The track, then the level from the bottom up + lines += "{\\an7\\pos(${n(cx - width / 2)},${n(top)})$edge\\1c&HFFFFFF&\\1a&HB0&\\p1}${rect(width, height)}{\\p0}" + val filled = height * level.percent / 100 + if (filled > 0) lines += "{\\an7\\pos(${n(cx - width / 2)},${n(top + height - filled)})\\bord0\\shad0\\1c&HFFFFFF&\\p1}${rect(width, filled)}{\\p0}" + // The icon, drawn on a 24 by 24 grid as Material's icons are + val icon = if (volume) { + // A speaker, with sound waves while there is any volume + "m 3 9 l 7 9 l 12 4 l 12 20 l 7 15 l 3 15" + if (level.percent > 0) { + " m 14 8.4 b 16.2 9.6 16.2 14.4 14 15.6 l 14.9 17.1 b 18.3 15.2 18.3 8.8 14.9 6.9" + + if (level.percent > 50) " m 16.4 4.6 b 21.4 7.6 21.4 16.4 16.4 19.4 l 17.3 20.9 b 23.4 17.2 23.4 6.8 17.3 3.1" else "" + } else "" + } else { + // A sun: a disc and eight rays + val rays = (0 until 8).joinToString(" ") { i -> + val angle = Math.toRadians(i * 45.0) + val x = 12 + 9 * kotlin.math.cos(angle) + val y = 12 + 9 * kotlin.math.sin(angle) + "m ${n(x - 1.3)} ${n(y - 1.3)} l ${n(x + 1.3)} ${n(y - 1.3)} ${n(x + 1.3)} ${n(y + 1.3)} ${n(x - 1.3)} ${n(y + 1.3)}" + } + "m 7.5 12 b 7.5 6 16.5 6 16.5 12 b 16.5 18 7.5 18 7.5 12 $rays" + } + lines += "{\\an7\\pos(${n(cx - 18)},${n(top - 48)})\\fscx150\\fscy150$edge\\1c&HFFFFFF&\\p1}$icon{\\p0}" + lines += "{\\an8\\pos(${n(cx)},${n(top + height + 12)})\\fs22\\bord1.5\\shad0\\3c&H000000&\\1c&HFFFFFF&}${level.percent}%" + return lines.joinToString("\n") +} + /** An ASS drawing of a circle of radius [r], from 0,0 to 2r,2r */ private fun circle(r: Double): String { val k = r * 0.5523 From 63b8e39f9948fa87c8d4d345807b4795565dc5ac Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sun, 4 Oct 2026 15:25:02 +0530 Subject: [PATCH 47/64] Desktop update: ask for administrator permission before quitting, so the prompt is seen The app quit right after starting msiexec. Windows only shows the UAC prompt in front for the program in front, so it waited unseen in the taskbar and was cancelled after two minutes (MSI status 1602), and the update did nothing. The app now starts the installer as administrator itself with ShellExecuteEx, quits once the user allows it, and stays open with a message when they do not. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lagradost/cloudstream4/WindowsElevated.kt | 81 +++++++++++++++++++ .../settings/AppMaintenanceSettings.kt | 10 ++- .../cloudstream4/update/AppUpdater.kt | 12 ++- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsElevated.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsElevated.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsElevated.kt new file mode 100644 index 00000000000..64074f6945e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsElevated.kt @@ -0,0 +1,81 @@ +package com.lagradost.cloudstream4 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.WString +import com.sun.jna.win32.StdCallLibrary +import java.awt.KeyboardFocusManager + +/** The shell32 function that starts a program as administrator, see shellapi.h */ +@Suppress("FunctionName") +private interface Shell32 : StdCallLibrary { + fun ShellExecuteExW(info: ShellExecuteInfo): Boolean +} + +@Structure.FieldOrder( + "cbSize", "fMask", "hwnd", "lpVerb", "lpFile", "lpParameters", "lpDirectory", "nShow", + "hInstApp", "lpIDList", "lpClass", "hkeyClass", "dwHotKey", "hIcon", "hProcess", +) +internal class ShellExecuteInfo : Structure() { + @JvmField var cbSize = 0 + @JvmField var fMask = 0 + @JvmField var hwnd: Pointer? = null + @JvmField var lpVerb: WString? = null + @JvmField var lpFile: WString? = null + @JvmField var lpParameters: WString? = null + @JvmField var lpDirectory: WString? = null + @JvmField var nShow = 0 + @JvmField var hInstApp: Pointer? = null + @JvmField var lpIDList: Pointer? = null + @JvmField var lpClass: WString? = null + @JvmField var hkeyClass: Pointer? = null + @JvmField var dwHotKey = 0 + @JvmField var hIcon: Pointer? = null + @JvmField var hProcess: Pointer? = null +} + +private val shell32: Shell32? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Native.load("shell32", Shell32::class.java) } + .onFailure { println("ERROR Shell32: shell32 could not be loaded: $it") } + .getOrNull() +} + +class ElevationDeclinedException : Exception("Windows was not given permission to install the update") + +/** + * Starts a program as administrator. Windows asks the user first, and shows that prompt in front + * only when the program asking is in front: one started by the app after it quit asks from the + * taskbar, unseen, and Windows cancels it two minutes later. So the app asks while it is open, and + * this returns once the user has answered. + */ +object WindowsElevated { + private const val SEE_MASK_NOASYNC = 0x100 + // Without it Windows shows its own error box, and then reports any failure as cancelled + private const val SEE_MASK_FLAG_NO_UI = 0x400 + private const val SW_SHOWNORMAL = 1 + private const val ERROR_CANCELLED = 1223 + + /** Starts [command], throwing [ElevationDeclinedException] when the user says no */ + fun start(command: List) { + val shell32 = shell32 ?: run { + ProcessBuilder(command).start() + return + } + val info = ShellExecuteInfo().apply { + cbSize = size() + fMask = SEE_MASK_NOASYNC or SEE_MASK_FLAG_NO_UI + hwnd = KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow?.let { Native.getWindowPointer(it) } + lpVerb = WString("runas") + lpFile = WString(command.first()) + lpParameters = WString(command.drop(1).joinToString(" ") { if (' ' in it) "\"$it\"" else it }) + nShow = SW_SHOWNORMAL + } + if (!shell32.ShellExecuteExW(info)) { + val error = Native.getLastError() + if (error == ERROR_CANCELLED) throw ElevationDeclinedException() + throw IllegalStateException("${command.first()} could not be started, Windows error $error") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt index fa49bf47bbf..449a92bea41 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/AppMaintenanceSettings.kt @@ -24,6 +24,8 @@ import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.AppData import com.lagradost.cloudstream4.AppDirs import com.lagradost.cloudstream4.AppVersion +import com.lagradost.cloudstream4.ElevationDeclinedException +import com.lagradost.cloudstream4.WindowsElevated import com.lagradost.cloudstream4.desktopPreferences import com.lagradost.cloudstream4.generated.resources.* import com.lagradost.cloudstream4.theme.AppShapes @@ -33,7 +35,9 @@ import com.lagradost.cloudstream4.update.AppRelease import com.lagradost.cloudstream4.update.AppUpdater import com.mihon.presentation.settings.Preference import com.mihon.presentation.settings.SearchableSettings +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource import java.nio.file.Path import kotlin.system.exitProcess @@ -62,9 +66,13 @@ private class UpdateState { progress = 0f try { val installer = updater.download(release, Path.of(System.getProperty("java.io.tmpdir"), "CloudStream-update")) { progress = it } - updater.startInstaller(installer) + // Windows asks for permission while the app is still in front, where its prompt is seen + withContext(Dispatchers.IO) { updater.startInstaller(installer, start = WindowsElevated::start) } // The installer replaces the app's files, which it can only do once the app has quit exitProcess(0) + } catch (_: ElevationDeclinedException) { + error = "Not updated: Windows needs your permission to install the update" + progress = null } catch (t: Throwable) { error = "Could not update: ${t.message ?: t}" progress = null diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt index 855a3331a6c..ad4daff0eb5 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/update/AppUpdater.kt @@ -92,11 +92,17 @@ class AppUpdater( /** * Starts the installer and returns, the caller then quits so the installer can replace the app. - * /passive shows only a progress bar and keeps the folder the app is installed in. + * /passive shows only a progress bar and keeps the folder the app is installed in. The app is + * installed for all users, so the installer runs as administrator: [start] starts it so, and + * throws when the user does not allow it, which leaves the app as it was. */ - fun startInstaller(installer: Path, appData: AppData = AppData.instance) { + fun startInstaller( + installer: Path, + appData: AppData = AppData.instance, + start: (List) -> Unit = { ProcessBuilder(it).start() }, + ) { + start(listOf("msiexec", "/i", installer.toAbsolutePath().toString(), "/passive")) appData.markUpdating() - ProcessBuilder("msiexec", "/i", installer.toAbsolutePath().toString(), "/passive").start() } companion object { From a434ec1aaae9d7e9573146c44629487f4f267c04 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 11:52:01 +0530 Subject: [PATCH 48/64] Desktop browser: no hidden Edge tabs in Alt+Tab The headless Edge behind WebViewResolver kept a hidden window, titled "about:blank - Microsoft Edge", that Windows lists in Alt+Tab where it can be neither seen nor closed. Its tabs are now hidden targets, which have no window, and the blank tab it starts with is closed. One hidden tab stays open so Edge keeps its session cookies. Popups that pages open are closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/browser/SystemBrowser.kt | 39 +++++++++++++++++-- .../cloudstream4/browser/SystemBrowserTest.kt | 28 +++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt index f8e97f7cf8f..5f96c2d437f 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/browser/SystemBrowser.kt @@ -71,6 +71,8 @@ class SystemBrowser( private val sessions = ConcurrentHashMap>() private val loads = AtomicInteger(0) private var idleJob: Job? = null + /** The hidden tab that keeps the browser's session, see [start] */ + @Volatile private var keeper: String? = null /** A check per site at a time in a window: the cookies once passed, else null */ private val checks = ConcurrentHashMap?>>() /** Sites whose check wants a person, with the page that asked and the user agent it had */ @@ -96,7 +98,7 @@ class SystemBrowser( idleJob?.cancel() try { start() - val target = call("Target.createTarget", mapOf("url" to "about:blank")).path("targetId").asText() + val target = newTab() try { val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() val events = Channel(Channel.UNLIMITED) @@ -232,7 +234,7 @@ class SystemBrowser( */ private suspend fun passInWindow(url: HttpUrl, agent: String?): List? = try { start() - val target = call("Target.createTarget", mapOf("url" to "about:blank")).path("targetId").asText() + val target = newTab() val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() // The cookie only counts for the user agent that passed useAgent(agent, session) @@ -295,7 +297,7 @@ class SystemBrowser( idleJob?.cancel() try { start() - val target = call("Target.createTarget", mapOf("url" to "about:blank")).path("targetId").asText() + val target = newTab() try { val session = call("Target.attachToTarget", mapOf("targetId" to target, "flatten" to true)).path("sessionId").asText() val events = Channel(Channel.UNLIMITED) @@ -513,6 +515,8 @@ class SystemBrowser( val id = message.path("id") if (!id.isMissingNode) { pending[id.asInt()]?.complete(message) + } else if (message.path("method").asText() == "Target.targetCreated") { + closePopup(message.path("params").path("targetInfo")) } else { message.path("sessionId").asText(null)?.let { sessions[it]?.trySend(message) } } @@ -535,9 +539,38 @@ class SystemBrowser( call("SystemInfo.getProcessInfo").path("processInfo").firstOrNull { it.path("type").asText() == "browser" }?.path("id")?.asLong() }.getOrNull()?.let(ProcessHandle::of)?.orElse(null)?.let { connection = Connection(it, socket) } userAgent = runCatching { call("Browser.getVersion").path("userAgent").asText().replace("Headless", "") }.getOrNull() + // Windows lists Edge's windows in Alt+Tab, also those of a browser without a window, which can be + // neither seen nor closed there. Its tabs are hidden ones, which have no window, and the blank + // tab it starts with is closed. One hidden tab stays open: without any, Edge ends the session + // and forgets its session cookies. Popups that pages open get a window, so they are closed + runCatching { call("Target.setDiscoverTargets", mapOf("discover" to true)) } + if (headless) runCatching { + val keeper = newTab() + call("Target.getTargets").path("targetInfos") + .filter { it.path("type").asText() == "page" && it.path("targetId").asText() != keeper } + .forEach { call("Target.closeTarget", mapOf("targetId" to it.path("targetId").asText())) } + this.keeper = keeper + } println("INFO SystemBrowser: started ${executable.name}, $userAgent") } + /** Opens a blank tab, without a window when headless, and returns its target */ + private suspend fun newTab(): String { + val hidden = if (headless) mapOf("hidden" to true, "background" to true) else emptyMap() + return call("Target.createTarget", mapOf("url" to "about:blank") + hidden).path("targetId").asText() + } + + /** The addresses of the open tabs, but for the one kept open */ + internal suspend fun tabs(): List = call("Target.getTargets").path("targetInfos") + .filter { it.path("type").asText() == "page" && it.path("targetId").asText() != keeper } + .map { it.path("url").asText() } + + /** Closes a tab a page opened, an ad most often: links are only looked for in the page itself */ + private fun closePopup(target: JsonNode) { + if (target.path("type").asText() != "page" || target.path("openerId").asText().isEmpty()) return + scope.launch { runCatching { call("Target.closeTarget", mapOf("targetId" to target.path("targetId").asText())) } } + } + private suspend fun devToolsAddress(portFile: File): List { while (true) { val lines = runCatching { portFile.readLines() }.getOrNull() diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt index 1412aa9b944..ebbb110ef71 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/browser/SystemBrowserTest.kt @@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.network.WebViewResolver import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import java.net.InetSocketAddress @@ -53,6 +54,22 @@ class SystemBrowserTest { exchange.sendResponseHeaders(200, page.size.toLong()) exchange.responseBody.use { it.write(page) } } + // Like an ad: opens more tabs, which Windows would list in Alt+Tab + createContext("/ads") { exchange -> + val page = """ + Ads + + + + """.trimIndent().toByteArray() + exchange.responseHeaders.add("Content-Type", "text/html") + exchange.sendResponseHeaders(200, page.size.toLong()) + exchange.responseBody.use { it.write(page) } + } createContext("/stream") { exchange -> exchange.sendResponseHeaders(200, 0) exchange.responseBody.close() @@ -87,6 +104,17 @@ class SystemBrowserTest { assertFalse(found.header("User-Agent").orEmpty().contains("Headless")) } + @Test + fun keepsNoTabOnceAPageIsLoaded() = runBlocking { + if (browser == null) return@runBlocking + DesktopWebView.engine = browser + val (found, _) = WebViewResolver(Regex("""ads\.m3u8"""), timeout = 30_000).resolveUsingWebView("$base/ads") + assertNotNull(found, "the page's request for the stream") + // Neither the tab the browser starts with nor the page's popups are left behind + delay(1_000) + assertEquals(emptyList(), browser.tabs()) + } + /** A test page behind Cloudflare's check (it answers 403 to plain clients), so only with -PcloudflareTest as well */ @Test fun passesCloudflaresCheck() = runBlocking { From f5b5982f8767145c1d2aec818b27c40e94aeebea Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 19:42:10 +0530 Subject: [PATCH 49/64] Desktop player: no flicker and no white line in full screen The video gave way to the bars by changing size: each time they came or went in full screen, mpv drew the picture at the new size for a frame or more before it was zoomed back. And at 125 % scaling the video and the bars were rounded apart, leaving a light line of the window between them. The video is now in the window over the app, keeping its size, and only the part to show is cut out of it with a window region. The picture no longer moves, and the cut is in the screen's own pixels. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 116 +++++++++++++----- .../cloudstream4/player/PlayerScreen.kt | 47 +++++-- 2 files changed, 121 insertions(+), 42 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index e5ad55a9995..900fa43ea03 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -1,26 +1,27 @@ package com.lagradost.cloudstream4.player -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.utils.ExtractorLink import com.sun.jna.Library import com.sun.jna.Native import com.sun.jna.Pointer +import com.sun.jna.win32.StdCallLibrary import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow import java.awt.Canvas import java.awt.Color +import java.awt.Rectangle import java.awt.Toolkit import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent import java.io.File +import javax.swing.JComponent +import javax.swing.JLayeredPane +import javax.swing.SwingUtilities import java.util.concurrent.TimeUnit import java.util.concurrent.CountDownLatch -import kotlin.math.ln /** The libmpv functions the player uses, see mpv/client.h */ @Suppress("FunctionName") @@ -36,6 +37,27 @@ internal interface MpvLibrary : Library { fun mpv_terminate_destroy(ctx: Pointer) } +/** The Windows functions that show only part of the video, see MpvPlayer.place */ +@Suppress("FunctionName") +private class Win32(val user: User, val gdi: Gdi) { + interface User : StdCallLibrary { + fun SetWindowRgn(hwnd: Pointer, region: Pointer?, redraw: Boolean): Int + } + + interface Gdi : StdCallLibrary { + fun CreateRectRgn(left: Int, top: Int, right: Int, bottom: Int): Pointer? + } + + companion object { + val instance: Win32? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Win32(Native.load("user32", User::class.java), Native.load("gdi32", Gdi::class.java)) } + .onFailure { println("ERROR Mpv: user32 or gdi32 could not be loaded: $it") } + .getOrNull() + } + } +} + /** Finds and loads libmpv-2.dll once */ object Mpv { private const val FILE = "libmpv-2.dll" @@ -135,9 +157,6 @@ data class MpvStatus( val speed: Double = 1.0, /** Seconds the subtitles are shown later, negative for earlier */ val subDelay: Double = 0.0, - /** The video's size as shown, its aspect ratio applied, 0 before it is known */ - val videoWidth: Int = 0, - val videoHeight: Int = 0, ) /** A subtitle file to show with a link, added again whenever the link changes */ @@ -402,29 +421,68 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun setSpeed(speed: Double) = whenStarted { command("set", "speed", speed.toString()) } /** - * Keeps the picture where it would be in [full] while only [visible], a part of it, shows the - * video: what the app puts beside the video then covers the picture's edge instead of shrinking it. - * Both in pixels, [visible] relative to [full]. The video's size is needed, from [status]. + * Placed in the app's layout, where it takes no room: while it is there, [canvas] is in the + * window, over the app, where [place] puts it. The video is a native window that nothing can be + * drawn over. Inside the app's layout it would be the size of the part it shows, and change size + * whenever the bars came or went, and mpv drew the picture again at each new size, which flashed. + * Over the app it keeps its size, and only the part of it to show is cut out of it, at once. */ - fun keepPicture(full: IntSize, visible: IntRect, videoWidth: Int, videoHeight: Int) = whenStarted { - if (videoWidth <= 0 || videoHeight <= 0 || visible.width <= 0 || visible.height <= 0 || - full.width <= 0 || full.height <= 0 || visible == IntRect(IntOffset.Zero, full) - ) { - command("set", "video-zoom", "0") - command("set", "video-pan-x", "0") - command("set", "video-pan-y", "0") - return@whenStarted + val anchor: JComponent = object : JComponent() { + private var layers: JLayeredPane? = null + + override fun addNotify() { + super.addNotify() + val pane = SwingUtilities.getRootPane(this)?.layeredPane ?: return + layers = pane + pane.add(canvas, JLayeredPane.PALETTE_LAYER as Any) + placeCanvas() } - // The scale mpv would fit the video to in each, and the zoom from the visible part's to the full one's - val fullScale = minOf(full.width.toDouble() / videoWidth, full.height.toDouble() / videoHeight) - val visibleScale = minOf(visible.width.toDouble() / videoWidth, visible.height.toDouble() / videoHeight) - // The pan moves the centre of the visible part back to the centre of the full area, in parts - // of the scaled video's size - val dx = full.width / 2.0 - visible.center.x - val dy = full.height / 2.0 - visible.center.y - command("set", "video-zoom", (ln(fullScale / visibleScale) / ln(2.0)).toString()) - command("set", "video-pan-x", (dx / (videoWidth * fullScale)).toString()) - command("set", "video-pan-y", (dy / (videoHeight * fullScale)).toString()) + + override fun removeNotify() { + layers?.remove(canvas) + layers = null + super.removeNotify() + } + } + + /** Where the whole video goes, in the window's content coordinates */ + private var whole: Rectangle? = null + /** The part of it to show, in pixels from its corner, null for all of it */ + private var shown: Rectangle? = null + /** Whether [shown] was set on the canvas's window */ + private var shownSet = false + + /** + * Puts the video at [area], in the window's content coordinates, and shows only [visible] of it, + * in pixels from the corner of [area], null for all of it: what the app draws there shows instead. + */ + fun place(area: Rectangle, visible: Rectangle?) { + if (area != whole) { + whole = area + shownSet = false + placeCanvas() + } + if (visible != shown || !shownSet) { + shown = visible + showPart() + } + } + + private fun placeCanvas() { + val area = whole ?: return + if (canvas.parent == null) return + canvas.setBounds(area) + showPart() + } + + private fun showPart() { + val windows = Win32.instance ?: return + if (!canvas.isDisplayable) return + val hwnd = Native.getComponentPointer(canvas) ?: return + val region = shown?.let { windows.gdi.CreateRectRgn(it.x, it.y, it.x + it.width, it.y + it.height) } + // Windows owns the region from then on, and frees it + windows.user.SetWindowRgn(hwnd, region, true) + shownSet = true } /** How subtitles look, from SubtitleStyle.mpvOptions. The one shown changes at once */ @@ -485,8 +543,6 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { muted = property("mute") == "yes", speed = property("speed")?.toDoubleOrNull() ?: 1.0, subDelay = property("sub-delay")?.toDoubleOrNull() ?: 0.0, - videoWidth = property("dwidth")?.toIntOrNull() ?: 0, - videoHeight = property("dheight")?.toIntOrNull() ?: 0, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 85021b79a25..8415e98e01a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -45,11 +46,14 @@ import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp @@ -97,8 +101,12 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream4.torrent.TorrServer import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import java.awt.Rectangle import java.io.File import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt /** Which subtitle the user picked, so it is shown again when the source changes */ private sealed interface SubtitleChoice { @@ -444,7 +452,7 @@ fun PlayerScreen( // The picture keeps its size and place whatever opens around it. In a window the bars sit above and // below the video and stay. In full screen the video fills the screen, and the bars that come and // go cover its edges, as do the panels in both. The video is a native window that nothing can be - // drawn over, so it gives way to them, and mpv keeps the picture where it was: see keepPicture + // drawn over: it keeps its size, and the part they cover is cut out of it, see MpvPlayer.anchor var area by remember { mutableStateOf(IntSize.Zero) } var topBarHeight by remember { mutableStateOf(0) } var controlBarHeight by remember { mutableStateOf(0) } @@ -459,11 +467,27 @@ fun PlayerScreen( right = area.width - if (side) sideWidth else 0, bottom = area.height - if (overlaid) controlBarHeight else 0, ) - LaunchedEffect(area, visible, status.videoWidth, status.videoHeight) { - player.keepPicture(area, visible, status.videoWidth, status.videoHeight) - } val density = LocalDensity.current fun Int.dp() = with(density) { toDp() } + // Where the video's area is in the window, in pixels, from the last layout + val placed = remember { arrayOfNulls(1) } + fun placeVideo() { + val at = placed[0] ?: return + // The window's units are 1.25 pixels at 125 % scaling: the video's window is rounded outward, + // and the part to show is in its own pixels + val scale = density.density + val left = floor(at.left / scale).toInt() + val top = floor(at.top / scale).toInt() + val right = ceil(at.right / scale).toInt() + val bottom = ceil(at.bottom / scale).toInt() + val dx = at.left - left * scale + val dy = at.top - top * scale + val part = visible.takeIf { it != IntRect(0, 0, area.width, area.height) }?.let { + Rectangle((it.left + dx).roundToInt(), (it.top + dy).roundToInt(), it.width, it.height) + } + player.place(Rectangle(left, top, right - left, bottom - top), part) + } + SideEffect { placeVideo() } Column( Modifier .fillMaxSize() @@ -471,14 +495,13 @@ fun PlayerScreen( .then(barMoves), ) { if (!fullscreen) topBar(Modifier) - Box(Modifier.fillMaxWidth().weight(1f).onSizeChanged { area = it }) { - SwingPanel( - factory = { player.canvas }, - modifier = Modifier - .padding(top = visible.top.dp(), end = (area.width - visible.right).dp(), bottom = (area.height - visible.bottom).dp()) - .fillMaxSize(), - background = Color.Black, - ) + Box( + Modifier.fillMaxWidth().weight(1f).onSizeChanged { area = it }.onGloballyPositioned { + placed[0] = it.boundsInWindow() + placeVideo() + }, + ) { + SwingPanel(factory = { player.anchor }, modifier = Modifier.size(0.dp)) if (overlaid) topBar(Modifier.align(Alignment.TopCenter).onSizeChanged { topBarHeight = it.height }) if (overlaid) controlBar(Modifier.align(Alignment.BottomCenter).onSizeChanged { controlBarHeight = it.height }) if (side) Row( From f7e487209842ff73c7738d0c3c07f70a7161607d Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 20:30:13 +0530 Subject: [PATCH 50/64] Desktop player: aspect ratio, volume up to 130 %, hidden cursor, bigger cache - An Aspect Ratio panel: Fit, Crop to Fill, 16:9, 4:3 and Stretch - The volume goes up to 130 %, from the slider, the wheel and the keys - In full screen the mouse pointer hides with the bars - mpv reads 30 s and up to 150 MB ahead, keeps 50 MB behind, and seeks exactly - The seek bar no longer shows how far the video is buffered Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 38 ++++++++++--- .../cloudstream4/player/PlayerScreen.kt | 55 +++++++++++++++---- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 900fa43ea03..517902ec84a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -162,6 +162,14 @@ data class MpvStatus( /** A subtitle file to show with a link, added again whenever the link changes */ data class MpvSubtitle(val file: File, val title: String, val select: Boolean) +enum class AspectMode(val label: String) { + Fit("Fit"), + Fill("Crop to Fill"), + SixteenNine("16:9"), + FourThree("4:3"), + Stretch("Stretch"), +} + sealed interface MpvEvent { /** F or a double click inside the video */ data object ToggleFullscreen : MpvEvent @@ -261,8 +269,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { addMouseWheelListener(mouse) } - private fun start(windowId: Long) = synchronized(this) { - if (started || closed) return + private fun start(windowId: Long): Unit = synchronized(this) { + if (started || closed) return@synchronized started = true option("wid", windowId.toString()) // The app draws its own controls. mpv's would not work anyway: embedded, it gets no mouse input @@ -274,10 +282,15 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { option("force-window", "yes") option("sub-auto", "no") option("drag-and-drop", "no") - // Memory: mpv reads up to 150 MB ahead and keeps 50 MB behind by default. This is still about a - // minute ahead of a 1080p stream, and enough behind to skip back without loading again - option("demuxer-max-bytes", "96MiB") - option("demuxer-max-back-bytes", "24MiB") + // VLC-parity buffer & network resilience + option("cache", "yes") + option("demuxer-readahead-secs", "30") + option("demuxer-max-bytes", "150MiB") + option("demuxer-max-back-bytes", "50MiB") + option("demuxer-lavf-probesize", "32768") + option("hr-seek", "default") + option("hr-seek-framedrop", "yes") + option("volume-max", "130") // Network: a stream that stops answering fails after 30 s instead of 60, so the next source can // play, and a dropped connection is opened again where it stopped option("network-timeout", "30") @@ -414,12 +427,23 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun changeVolume(delta: Int) = whenStarted { command("add", "volume", delta.toString()) } - fun setVolume(volume: Int) = whenStarted { command("set", "volume", volume.coerceIn(0, 100).toString()) } + fun setVolume(volume: Int) = whenStarted { command("set", "volume", volume.coerceIn(0, 130).toString()) } fun toggleMute() = whenStarted { command("cycle", "mute") } fun setSpeed(speed: Double) = whenStarted { command("set", "speed", speed.toString()) } + /** How the picture fits the video's area: [AspectMode.Fill] crops it, [AspectMode.Stretch] distorts it */ + fun setAspectMode(mode: AspectMode) = whenStarted { + command("set", "keepaspect", if (mode == AspectMode.Stretch) "no" else "yes") + command("set", "panscan", if (mode == AspectMode.Fill) "1.0" else "0.0") + command("set", "video-aspect-override", when (mode) { + AspectMode.SixteenNine -> "16:9" + AspectMode.FourThree -> "4:3" + else -> "-1" + }) + } + /** * Placed in the app's layout, where it takes no room: while it is there, [canvas] is in the * window, over the app, where [place] puts it. The video is a native window that nothing can be diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 8415e98e01a..bf59ae7b4c2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -79,6 +79,7 @@ import com.lagradost.cloudstream4.browse.Message import com.lagradost.cloudstream4.browse.formatTime import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.arrow_back +import com.lagradost.cloudstream4.generated.resources.aspect_ratio_24px import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_24 import com.lagradost.cloudstream4.generated.resources.baseline_fullscreen_exit_24 import com.lagradost.cloudstream4.generated.resources.ic_baseline_audiotrack_24 @@ -147,6 +148,7 @@ fun PlayerScreen( val scope = rememberCoroutineScope() var subtitleMessage by remember { mutableStateOf(null) } var panel by remember { mutableStateOf(null) } + var aspectMode by remember { mutableStateOf(AspectMode.Fit) } val currentFullscreen by rememberUpdatedState(onToggleFullscreen) val currentBack by rememberUpdatedState(onBack) @@ -230,9 +232,16 @@ fun PlayerScreen( lastPointer[0] = at activity = System.nanoTime() } + val blankCursor = remember { + val img = java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB) + java.awt.Toolkit.getDefaultToolkit().createCustomCursor(img, java.awt.Point(0, 0), "blank") + } DisposableEffect(player) { player.onMouseMove = ::pointerMoved - onDispose { player.onMouseMove = null } + onDispose { + player.onMouseMove = null + player.canvas.cursor = java.awt.Cursor.getDefaultCursor() + } } LaunchedEffect(fullscreen, activity, panel) { barsShown = true @@ -242,6 +251,9 @@ fun PlayerScreen( } } val showBars = !fullscreen || barsShown + LaunchedEffect(fullscreen, showBars) { + player.canvas.cursor = if (fullscreen && !showBars) blankCursor else java.awt.Cursor.getDefaultCursor() + } // The mouse wheel over the video works as swipes do in the Android player: on the right half it // sets the volume, on the left half the brightness, each shown by a level on the other side var level by remember { mutableStateOf(null) } @@ -249,8 +261,9 @@ fun PlayerScreen( val wheelRest = remember { doubleArrayOf(0.0) } fun changeLevel(kind: LevelKind, delta: Int) { // The level shown is newer than the status, which is read twice a second + val max = if (kind == LevelKind.Volume) 130 else 100 val now = level?.takeIf { it.kind == kind }?.percent ?: if (kind == LevelKind.Volume) status.volume else brightness - val next = (now + delta).coerceIn(0, 100) + val next = (now + delta).coerceIn(0, max) when (kind) { LevelKind.Volume -> { if (status.muted && delta > 0) player.toggleMute() @@ -442,6 +455,7 @@ fun PlayerScreen( status = status, state = state, panel = panel, + aspectMode = aspectMode, subtitleOn = status.tracks.any { it.type == "sub" && it.selected }, fullscreen = fullscreen, onPanel = { panel = it }, @@ -527,6 +541,7 @@ fun PlayerScreen( panel = open, state = state, status = status, + aspectMode = aspectMode, downloaded = downloaded, loadingSubtitle = loadingSubtitle, onSelect = { viewModel.onAction(LinksAction.Select(it)) }, @@ -540,6 +555,10 @@ fun PlayerScreen( onTextSize = { size -> subtitleSettings.setStyle(subtitleSettings.style.value.copy(fixedTextSize = size)) }, onAudioTrack = player::selectAudio, onSpeed = player::setSpeed, + onAspectMode = { mode -> + aspectMode = mode + player.setAspectMode(mode) + }, ) } } @@ -620,7 +639,7 @@ private fun host(url: String): String? = runCatching { java.net.URI(url).host?.r private fun MpvTrack.isFile(file: File) = external && externalFilename?.let { File(it).absolutePath.equals(file.absolutePath, ignoreCase = true) } == true -private enum class Panel(val title: String) { Source("Source"), Subtitles("Subtitles"), Audio("Audio"), Speed("Speed") } +private enum class Panel(val title: String) { Source("Source"), Subtitles("Subtitles"), Audio("Audio"), Speed("Speed"), Aspect("Aspect Ratio") } private val speeds = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0) @@ -632,8 +651,8 @@ private object PlayerColors { val panel = Color(0xFF18181C) val content = Color.White val secondary = Color.White.copy(alpha = 0.68f) - val track = Color.White.copy(alpha = 0.22f) - val buffered = Color.White.copy(alpha = 0.42f) + val track = Color(0x28FFFFFF) + val buffered = Color(0x38FFFFFF) val selected = Color.White.copy(alpha = 0.10f) } @@ -685,6 +704,7 @@ private fun levelAss(level: Level): String { fun n(v: Double) = "%.1f".format(java.util.Locale.ROOT, v) fun rect(w: Double, h: Double) = "m 0 0 l ${n(w)} 0 ${n(w)} ${n(h)} 0 ${n(h)}" val volume = level.kind == LevelKind.Volume + val maxPercent = if (volume) 130 else 100 val cx = if (volume) 110.0 else 1170.0 val top = 260.0 val height = 200.0 @@ -693,7 +713,7 @@ private fun levelAss(level: Level): String { val lines = mutableListOf() // The track, then the level from the bottom up lines += "{\\an7\\pos(${n(cx - width / 2)},${n(top)})$edge\\1c&HFFFFFF&\\1a&HB0&\\p1}${rect(width, height)}{\\p0}" - val filled = height * level.percent / 100 + val filled = (height * level.percent / maxPercent).coerceIn(0.0, height) if (filled > 0) lines += "{\\an7\\pos(${n(cx - width / 2)},${n(top + height - filled)})\\bord0\\shad0\\1c&HFFFFFF&\\p1}${rect(width, filled)}{\\p0}" // The icon, drawn on a 24 by 24 grid as Material's icons are val icon = if (volume) { @@ -774,6 +794,7 @@ private fun ControlBar( status: MpvStatus, state: LinksState, panel: Panel?, + aspectMode: AspectMode = AspectMode.Fit, subtitleOn: Boolean, fullscreen: Boolean, onPanel: (Panel?) -> Unit, @@ -796,7 +817,7 @@ private fun ControlBar( Column(modifier.fillMaxWidth().background(PlayerColors.bar).padding(start = 12.dp, end = 12.dp, top = 6.dp, bottom = 4.dp)) { SeekBar( value = if (duration > 0) position / duration else 0f, - buffered = if (duration > 0) (status.bufferedTo.toFloat() / duration).coerceIn(0f, 1f) else 0f, + buffered = 0f, enabled = duration > 0, accent = accent, onChange = { dragging = it * duration }, @@ -826,11 +847,11 @@ private fun ControlBar( onClick = player::toggleMute, ) SeekBar( - value = (volume ?: status.volume.toFloat()) / 100f, - accent = PlayerColors.content, + value = (volume ?: status.volume.toFloat()) / 130f, + accent = accent, onChange = { - volume = it * 100 - player.setVolume((it * 100).toInt()) + volume = it * 130 + player.setVolume((it * 130).toInt()) }, onDone = { volume = null }, modifier = Modifier.width(96.dp), @@ -864,6 +885,11 @@ private fun ControlBar( label = state.selectedLink?.let { Qualities.getStringByInt(it.quality).ifBlank { null } } ?: "Source", enabled = state.links.isNotEmpty(), ) { onPanel(if (panel == Panel.Source) null else Panel.Source) } + PanelButton( + Res.drawable.aspect_ratio_24px, "Aspect Ratio", panel == Panel.Aspect, + label = if (aspectMode != AspectMode.Fit) aspectMode.label else null, + active = aspectMode != AspectMode.Fit, + ) { onPanel(if (panel == Panel.Aspect) null else Panel.Aspect) } PlayerIcon( if (fullscreen) Res.drawable.baseline_fullscreen_exit_24 else Res.drawable.baseline_fullscreen_24, if (fullscreen) "Leave full screen" else "Full screen", @@ -986,7 +1012,6 @@ private fun SeekBar( Canvas(Modifier.fillMaxWidth().height(if (thick) 6.dp else 3.dp)) { val r = CornerRadius(size.height / 2) drawRoundRect(PlayerColors.track, cornerRadius = r) - if (buffered > 0) drawRoundRect(PlayerColors.buffered, size = Size(size.width * buffered, size.height), cornerRadius = r) drawRoundRect(accent, size = Size(size.width * value.coerceIn(0f, 1f), size.height), cornerRadius = r) } if (thick) Canvas(Modifier.fillMaxWidth().height(14.dp)) { @@ -1000,6 +1025,7 @@ private fun SidePanel( panel: Panel, state: LinksState, status: MpvStatus, + aspectMode: AspectMode = AspectMode.Fit, downloaded: Map, loadingSubtitle: String?, onSelect: (String) -> Unit, @@ -1011,6 +1037,7 @@ private fun SidePanel( onTextSize: (Float?) -> Unit, onAudioTrack: (Int) -> Unit, onSpeed: (Double) -> Unit, + onAspectMode: (AspectMode) -> Unit = {}, ) { LazyColumn(Modifier.width(380.dp).fillMaxHeight().background(PlayerColors.panel)) { item { Text(panel.title, style = MaterialTheme.typography.titleMedium, color = PlayerColors.content, modifier = Modifier.padding(16.dp)) } @@ -1079,6 +1106,10 @@ private fun SidePanel( Panel.Speed -> items(speeds) { speed -> Choice(speedLabel(speed) + if (speed == 1.0) " (normal)" else "", selected = abs(status.speed - speed) < 0.01) { onSpeed(speed) } } + + Panel.Aspect -> items(AspectMode.entries) { mode -> + Choice(mode.label, selected = aspectMode == mode) { onAspectMode(mode) } + } } } } From 6a3e0170d98801d231d24955a6d5fe34a8dd354c Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 21:16:00 +0530 Subject: [PATCH 51/64] Desktop player: aspect menu zooms, and closes once a choice is made A 16:9 video on a 16:9 screen looks the same fitted, filled, set to 16:9 or stretched, so most of the aspect choices seemed to do nothing. Zoom 110 %, 125 % and 150 % crop into the picture, also past black bars that are part of the video itself. The panel closes after a choice: it covered part of the picture, where the change would show. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../kotlin/com/lagradost/cloudstream4/player/Mpv.kt | 12 +++++++++++- .../lagradost/cloudstream4/player/PlayerScreen.kt | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 517902ec84a..4e49fc7e2f5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -22,6 +22,7 @@ import javax.swing.JLayeredPane import javax.swing.SwingUtilities import java.util.concurrent.TimeUnit import java.util.concurrent.CountDownLatch +import kotlin.math.log2 /** The libmpv functions the player uses, see mpv/client.h */ @Suppress("FunctionName") @@ -162,9 +163,16 @@ data class MpvStatus( /** A subtitle file to show with a link, added again whenever the link changes */ data class MpvSubtitle(val file: File, val title: String, val select: Boolean) -enum class AspectMode(val label: String) { +/** + * How the picture fits the video's area. A 16:9 video on a 16:9 screen looks the same fitted, filled + * or stretched: the zooms crop into it, also past black bars that are part of the video itself + */ +enum class AspectMode(val label: String, val zoom: Double = 1.0) { Fit("Fit"), Fill("Crop to Fill"), + Zoom110("Zoom 110%", 1.10), + Zoom125("Zoom 125%", 1.25), + Zoom150("Zoom 150%", 1.50), SixteenNine("16:9"), FourThree("4:3"), Stretch("Stretch"), @@ -437,6 +445,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { fun setAspectMode(mode: AspectMode) = whenStarted { command("set", "keepaspect", if (mode == AspectMode.Stretch) "no" else "yes") command("set", "panscan", if (mode == AspectMode.Fill) "1.0" else "0.0") + // mpv's zoom is in powers of 2 + command("set", "video-zoom", log2(mode.zoom).toString()) command("set", "video-aspect-override", when (mode) { AspectMode.SixteenNine -> "16:9" AspectMode.FourThree -> "4:3" diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index bf59ae7b4c2..743b9794eb1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -558,6 +558,8 @@ fun PlayerScreen( onAspectMode = { mode -> aspectMode = mode player.setAspectMode(mode) + // The panel covers part of the picture: closed, the change shows + panel = null }, ) } From 46d0dfeb338d25f00ec055991c621eaf53f97477 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 21:36:32 +0530 Subject: [PATCH 52/64] Desktop player: a new source carries on where the last one was, also from servers that cannot seek mpv starts a file at the position it is given, but only when it can jump into the file as it opens. A server that ignores range requests does not let it, and the new source started from the beginning. Once the file plays, the player checks where it started and seeks there if it was more than 5 s short. Each resume is logged, with whether the stream was seekable. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index 4e49fc7e2f5..d187b32a146 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -214,6 +214,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { /** From loadfile until the first frame plays */ @Volatile private var opening = false private val afterLoad = mutableListOf<() -> Unit>() + /** Where the file opening should start, in seconds, until it has started there */ + @Volatile private var resumeAt: Double? = null private val _events = Channel(Channel.UNLIMITED) val events: Flow = _events.receiveAsFlow() @@ -340,7 +342,10 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { val event = lib.mpv_wait_event(ctx, -1.0) when (event.getInt(0)) { MPV_EVENT_SHUTDOWN -> return - MPV_EVENT_PLAYBACK_RESTART -> opening = false + MPV_EVENT_PLAYBACK_RESTART -> { + opening = false + resumeAt?.let(::checkResumed) + } MPV_EVENT_FILE_LOADED -> synchronized(this) { fileLoaded = true afterLoad.forEach { it() } @@ -396,6 +401,7 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { command("change-list", "http-header-fields", "clr", "") headers.forEach { (key, value) -> command("change-list", "http-header-fields", "append", "$key: $value") } command("set", "start", startSeconds?.let { "+$it" } ?: "none") + resumeAt = startSeconds?.takeIf { it > 0 } // keep-open pauses at the end of a file, which would carry over to the next one command("set", "pause", "no") fileLoaded = false @@ -407,6 +413,22 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { subtitles.forEach { addSubtitle(it.file, it.title, it.select) } } + /** + * Once the file plays, checks it started where it was asked to. Some servers do not let mpv jump + * into the file as it opens, and it then starts from the beginning: it is asked to seek once more + */ + private fun checkResumed(wanted: Double) { + resumeAt = null + val at = property("time-pos")?.toDoubleOrNull() ?: return + val seekable = property("seekable") + if (at >= wanted - 5) { + println("INFO MpvPlayer: resumed at ${at.toInt()} s of ${wanted.toInt()} s") + return + } + println("WARNING MpvPlayer: started at ${at.toInt()} s, not ${wanted.toInt()} s (seekable: $seekable), seeking there") + command("seek", wanted.toString(), "absolute") + } + /** Runs once the current file is loaded */ private fun whenLoaded(action: () -> Unit) = whenStarted { if (fileLoaded) action() else afterLoad += action From be7ec2b9017a8e475bbe0b0e53deea9a6512ae37 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 21:57:56 +0530 Subject: [PATCH 53/64] Desktop: a Search button on title pages, and a smaller Continue watching that scrolls - The title page has a Search button: it opens Search with the title typed in and searched, to find it in the other extensions - Continue watching is part of the home page's list, so it scrolls away with the rows instead of staying at the top - Its cards are small, 84 dp high: a thumbnail, the title, the episode and the minutes left, the progress, and an x to drop it Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 9 ++- .../cloudstream4/browse/DetailScreen.kt | 13 +++- .../cloudstream4/browse/HomeScreen.kt | 42 ++++++----- .../cloudstream4/browse/LibraryScreen.kt | 72 ++++++++++++++----- 4 files changed, 98 insertions(+), 38 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index be9194c2b9f..8d58229649c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -63,6 +63,7 @@ import com.lagradost.cloudstream4.home.HomeAction import com.lagradost.cloudstream4.home.HomeViewModel import com.lagradost.cloudstream4.plugins.DesktopPluginManager import com.lagradost.cloudstream4.search.DataStoreSearchHistory +import com.lagradost.cloudstream4.search.SearchAction import com.lagradost.cloudstream4.search.SearchViewModel import com.lagradost.cloudstream4.settings.ExtensionsScreen import com.lagradost.cloudstream4.settings.SettingsHomeScreen @@ -287,7 +288,10 @@ fun AppShell(navigator: AppNavigator) { Box(Modifier.weight(1f).fillMaxHeight()) { if (page != null) { CompositionLocalProvider(LocalViewModelStoreOwner provides page) { - PageContent(page, navigator) + PageContent(page, navigator) { query -> + navigator.select(Destination.Search) + search.onAction(SearchAction.SearchFor(query)) + } } } else when (navigator.destination) { Destination.Settings -> { @@ -326,7 +330,7 @@ private fun ExtensionToasts(host: SnackbarHostState) { } @Composable -private fun PageContent(page: Page, navigator: AppNavigator) { +private fun PageContent(page: Page, navigator: AppNavigator, onSearch: (query: String) -> Unit) { val apiName = when (page) { is Page.Details -> page.apiName is Page.Player -> page.request.apiName @@ -353,6 +357,7 @@ private fun PageContent(page: Page, navigator: AppNavigator) { onBack = { navigator.back() }, onPlay = navigator::play, onOpen = navigator::openDetails, + onSearch = onSearch, ) is Page.Player -> PlayerScreen( diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index 948b333e739..6226b29bf89 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -55,6 +55,7 @@ import com.lagradost.cloudstream4.detail.PlayRequest import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.arrow_back import com.lagradost.cloudstream4.generated.resources.play_arrow_24px +import com.lagradost.cloudstream4.generated.resources.search_icon import com.lagradost.cloudstream4.library.PlaybackPosition import com.lagradost.cloudstream4.library.WatchType import com.lagradost.cloudstream4.theme.AppFilterChip @@ -69,6 +70,8 @@ fun DetailScreen( onBack: () -> Unit, onPlay: (PlayRequest) -> Unit, onOpen: (apiName: String, url: String) -> Unit, + /** Looks for the title in every extension, from the Search page */ + onSearch: (query: String) -> Unit, downloads: Downloads = Downloads.instance, ) { val state by viewModel.state.collectAsState() @@ -82,7 +85,7 @@ fun DetailScreen( WhiteButton(onClick = { viewModel.onAction(DetailAction.Retry) }) { Text("Try again") } } - DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, downloads) + DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, onSearch, downloads) } } } @@ -93,6 +96,7 @@ private fun Details( viewModel: DetailViewModel, onPlay: (PlayRequest) -> Unit, onOpen: (apiName: String, url: String) -> Unit, + onSearch: (query: String) -> Unit, downloads: Downloads, ) { val response = state.response ?: return @@ -137,6 +141,7 @@ private fun Details( onPlay = { fromStart -> play(viewModel.playRequest(fromStart = fromStart), null) }, onResume = { episode -> play(viewModel.playRequest(episode), episode) }, onBookmark = { viewModel.onAction(DetailAction.SetBookmark(it)) }, + onSearch = { onSearch(response.name) }, download = if (state.movieData != null && response.type != TvType.Live) downloadButton(null) else null, ) } @@ -186,6 +191,7 @@ private fun Header( onPlay: (fromStart: Boolean) -> Unit, onResume: (com.lagradost.cloudstream3.Episode) -> Unit, onBookmark: (WatchType) -> Unit, + onSearch: () -> Unit, /** The movie's download button, null for a series or a live stream */ download: (@Composable () -> Unit)?, ) { @@ -239,6 +245,11 @@ private fun Header( } } LibraryButton(state.bookmark, onBookmark) + // The same title from other extensions, when this one's links do not play + BlackButton(onClick = onSearch) { + Icon(painterResource(Res.drawable.search_icon), contentDescription = null) + Text("Search", modifier = Modifier.padding(start = 8.dp)) + } download?.invoke() } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index 02f7214e618..edd53c8023c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -45,27 +46,34 @@ fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit, openDetails Column(Modifier.fillMaxSize()) { if (state.providers.isNotEmpty()) TopBar(state, viewModel::onAction) - // Above the provider's rows, so it is there even when the provider fails to load - ContinueWatchingRow(DataStoreWatchStore.instance, openDetails, Modifier.padding(top = 8.dp)) + // One list, so Continue watching scrolls away with the rows below it + LazyColumn(contentPadding = PaddingValues(bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + // Above the provider's rows, so it is there even when the provider fails to load + item { ContinueWatchingRow(DataStoreWatchStore.instance, openDetails, Modifier.padding(top = 8.dp)) } - when (val status = state.status) { - HomeStatus.NoProviders -> Message( - "No extensions with a home page", - "Install an extension to browse it here. If you already have, check the extension languages and media types under Settings > Providers.", - ) { WhiteButton(onClick = openExtensions) { Text("Open Extensions") } } - - HomeStatus.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + // What fills the rest of the page while there are no rows + fun LazyListScope.whole(content: @Composable () -> Unit) = item { + Box(Modifier.fillParentMaxWidth().fillParentMaxHeight(0.8f), contentAlignment = Alignment.Center) { content() } } + when (val status = state.status) { + HomeStatus.NoProviders -> whole { + Message( + "No extensions with a home page", + "Install an extension to browse it here. If you already have, check the extension languages and media types under Settings > Providers.", + ) { WhiteButton(onClick = openExtensions) { Text("Open Extensions") } } + } - is HomeStatus.Failed -> Message("${state.selected} could not be loaded", status.message) { - WhiteButton(onClick = { viewModel.onAction(HomeAction.Reload) }) { Text("Try again") } - } + HomeStatus.Loading -> whole { CircularProgressIndicator() } + + is HomeStatus.Failed -> whole { + Message("${state.selected} could not be loaded", status.message) { + WhiteButton(onClick = { viewModel.onAction(HomeAction.Reload) }) { Text("Try again") } + } + } - HomeStatus.Done -> if (state.rows.isEmpty()) { - Message("${state.selected} returned an empty home page") - } else { - LazyColumn(contentPadding = PaddingValues(bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + HomeStatus.Done -> if (state.rows.isEmpty()) { + whole { Message("${state.selected} returned an empty home page") } + } else { items(state.rows) { row -> PosterRow( title = row.name, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt index a121f0af39c..68d77a2f9c8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt @@ -1,17 +1,20 @@ package com.lagradost.cloudstream4.browse +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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells @@ -19,10 +22,11 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -34,10 +38,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.close +import com.lagradost.cloudstream4.library.PlaybackPosition +import com.lagradost.cloudstream4.library.ResumeEntry import com.lagradost.cloudstream4.library.TitleHeader import com.lagradost.cloudstream4.library.WatchStore import com.lagradost.cloudstream4.library.WatchType import com.lagradost.cloudstream4.theme.AppFilterChip +import org.jetbrains.compose.resources.painterResource /** The titles the user put in a library list, one list at a time, most recently changed first */ @OptIn(ExperimentalLayoutApi::class) @@ -97,8 +106,9 @@ private fun TitleCard(header: TitleHeader, modifier: Modifier = Modifier, onClic } /** - * The titles the user started, newest first, like the Android app's Continue watching row. Each - * shows how far its episode got; opening one goes to its page, where Resume picks it up. + * The titles the user started, newest first, like the Android app's Continue watching row. Each is + * a small card: the poster, the title, how far its episode got, and a button to drop it from the + * row. Opening one goes to its page, where Resume picks it up. */ @Composable fun ContinueWatchingRow(store: WatchStore, openDetails: (apiName: String, url: String) -> Unit, modifier: Modifier = Modifier) { @@ -110,21 +120,47 @@ fun ContinueWatchingRow(store: WatchStore, openDetails: (apiName: String, url: S LazyRow(contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { items(entries, key = { it.header.id }) { entry -> val position = remember(version, entry) { store.position(entry.episodeId ?: entry.header.id) } - Column(Modifier.width(150.dp)) { - Box { - TitleCard(entry.header, onClick = { openDetails(entry.header.apiName, entry.header.url) }) - } - if (position != null && !position.watched) { - LinearProgressIndicator(progress = { position.fraction }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) - } - Row { - val episode = listOfNotNull(entry.season?.let { "S$it" }, entry.episode?.let { "E$it" }).joinToString(" ") - if (episode.isNotEmpty()) Text(episode, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(top = 12.dp)) - else Box(Modifier.weight(1f)) - TextButton(onClick = { store.removeResume(entry.header.id) }) { Text("Remove") } - } - } + ContinueCard( + entry = entry, + position = position, + onClick = { openDetails(entry.header.apiName, entry.header.url) }, + onRemove = { store.removeResume(entry.header.id) }, + ) } } } } + +@Composable +private fun ContinueCard(entry: ResumeEntry, position: PlaybackPosition?, onClick: () -> Unit, onRemove: () -> Unit) { + val header = entry.header + Row( + Modifier + .width(280.dp) + .height(84.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), + ) { + RemoteImage(header.posterUrl, header.posterHeaders, contentDescription = header.name, modifier = Modifier.width(56.dp).fillMaxHeight()) + Column(Modifier.weight(1f).fillMaxHeight().padding(start = 10.dp, top = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.SpaceBetween) { + Text(header.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + val episode = listOfNotNull(entry.season?.let { "S$it" }, entry.episode?.let { "E$it" }).joinToString(" ") + val left = position?.takeIf { !it.watched && it.durationMs > 0 }?.let { "${(it.durationMs - it.positionMs) / 60_000} min left" } + Text( + listOfNotNull(episode.ifEmpty { null }, left).joinToString(" · ").ifEmpty { header.apiName }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + LinearProgressIndicator( + progress = { if (position == null || position.watched) 0f else position.fraction }, + modifier = Modifier.fillMaxWidth().height(3.dp), + ) + } + IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Icon(painterResource(Res.drawable.close), contentDescription = "Remove from Continue watching", modifier = Modifier.size(16.dp)) + } + } +} From 42d4dadd7b2d6a576eac063c78f7a1fd990a7c98 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 22:15:25 +0530 Subject: [PATCH 54/64] Desktop UI: cards grow under the mouse, rows have arrows, darker theme, bigger headings - Posters and Continue watching cards grow 6 % under the mouse, over their neighbours, with a shadow - Rows show arrows at their ends while the mouse is over them, which scroll by most of the row's width: a mouse cannot swipe - The default dark theme is neutral and darker, after Netflix: the background #141414, surfaces a step lighter, gray text #B3B3B3. The other themes keep the Android app's gray text - Row titles are bold and larger, and the rows have 32 dp margins Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/browse/LibraryScreen.kt | 8 +- .../lagradost/cloudstream4/browse/Posters.kt | 104 ++++++++++++++++-- .../cloudstream4/browse/SearchScreen.kt | 4 +- .../drawable/chevron_left.xml | 9 ++ .../drawable/chevron_right.xml | 9 ++ .../lagradost/cloudstream4/theme/Palette.kt | 18 +-- .../lagradost/cloudstream4/theme/Scheme.kt | 6 +- 7 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/chevron_left.xml create mode 100644 shared/src/commonMain/composeResources/drawable/chevron_right.xml diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt index 68d77a2f9c8..1f045e020f9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream4.browse import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -16,7 +17,6 @@ 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.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items @@ -116,8 +116,8 @@ fun ContinueWatchingRow(store: WatchStore, openDetails: (apiName: String, url: S val entries = remember(version) { store.resumeEntries().sortedByDescending { it.updateTime } } if (entries.isEmpty()) return Column(modifier.fillMaxWidth()) { - Text("Continue watching", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) - LazyRow(contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RowTitle("Continue watching", Modifier.padding(horizontal = PageGutter, vertical = 4.dp)) + ScrollingRow { items(entries, key = { it.header.id }) { entry -> val position = remember(version, entry) { store.position(entry.episodeId ?: entry.header.id) } ContinueCard( @@ -134,9 +134,11 @@ fun ContinueWatchingRow(store: WatchStore, openDetails: (apiName: String, url: S @Composable private fun ContinueCard(entry: ResumeEntry, position: PlaybackPosition?, onClick: () -> Unit, onRemove: () -> Unit) { val header = entry.header + val interaction = remember { MutableInteractionSource() } Row( Modifier .width(280.dp) + .hoverZoom(interaction) .height(84.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt index e43633390a9..ff80aeec454 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -1,6 +1,31 @@ package com.lagradost.cloudstream4.browse import androidx.compose.foundation.background +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.zIndex +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.chevron_left +import com.lagradost.cloudstream4.generated.resources.chevron_right +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -94,12 +119,32 @@ fun RemoteImage(url: String?, headers: Map?, contentDescription: } } +/** The space at the sides of the pages' rows */ +val PageGutter = 32.dp + +/** Grows a little under the mouse, as cards do in streaming apps, over its neighbours */ +@Composable +fun Modifier.hoverZoom(interaction: MutableInteractionSource, enabled: Boolean = true): Modifier { + val hovered by interaction.collectIsHoveredAsState() + val scale by animateFloatAsState(if (hovered && enabled) 1.06f else 1f, tween(150)) + return zIndex(if (hovered) 1f else 0f) + .graphicsLayer { + scaleX = scale + scaleY = scale + shadowElevation = (scale - 1f) * 200f + shape = RoundedCornerShape(8.dp) + } + .hoverable(interaction) +} + /** A poster with its title underneath */ @Composable fun PosterCard(item: SearchResponse, horizontal: Boolean, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) { + val interaction = remember { MutableInteractionSource() } Column( modifier .width(if (horizontal) horizontalWidth else verticalWidth) + .hoverZoom(interaction, enabled = onClick != null) .then(if (onClick != null) Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick) else Modifier) ) { RemoteImage( @@ -118,8 +163,15 @@ fun PosterCard(item: SearchResponse, horizontal: Boolean, modifier: Modifier = M } } +/** A row's title, the same on every page */ +@Composable +fun RowTitle(title: String, modifier: Modifier = Modifier) { + Text(title, style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), modifier = modifier) +} + /** - * A titled, sideways-scrolling row of posters. + * A titled, sideways-scrolling row of posters. Under the mouse, arrows at its ends scroll it by + * most of its width, as the mouse cannot swipe. * * @param trailing shown after the last poster, for example a "More" button */ @@ -136,20 +188,17 @@ fun PosterRow( ) { Column(modifier.fillMaxWidth()) { Row( - Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + Modifier.padding(horizontal = PageGutter, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text(title, style = MaterialTheme.typography.titleMedium) + RowTitle(title) subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } if (loading) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) } - LazyRow( - contentPadding = PaddingValues(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + ScrollingRow { // No keys, providers sometimes list the same item twice and duplicate keys crash the row items(items) { item -> PosterCard(item, horizontal, onClick = onItemClick?.let { { it(item) } }) } if (trailing != null) item { trailing() } @@ -157,6 +206,47 @@ fun PosterRow( } } +/** A sideways row with the arrows of [PosterRow], room around its cards to grow under the mouse */ +@Composable +fun ScrollingRow(content: LazyListScope.() -> Unit) { + val state = rememberLazyListState() + val scope = rememberCoroutineScope() + val hover = remember { MutableInteractionSource() } + val hovered by hover.collectIsHoveredAsState() + var width by remember { mutableStateOf(0) } + Box(Modifier.fillMaxWidth().hoverable(hover).onSizeChanged { width = it.width }) { + LazyRow( + state = state, + contentPadding = PaddingValues(horizontal = PageGutter, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) + Row(Modifier.matchParentSize()) { + if (hovered && state.canScrollBackward) RowArrow(Res.drawable.chevron_left, "Scroll back") { + scope.launch { state.animateScrollBy(-width * 0.8f) } + } + Spacer(Modifier.weight(1f)) + if (hovered && state.canScrollForward) RowArrow(Res.drawable.chevron_right, "Scroll on") { + scope.launch { state.animateScrollBy(width * 0.8f) } + } + } + } +} + +@Composable +private fun RowArrow(icon: DrawableResource, description: String, onClick: () -> Unit) { + Box( + Modifier + .width(48.dp) + .fillMaxHeight() + .background(Color.Black.copy(alpha = 0.55f)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(painterResource(icon), contentDescription = description, tint = Color.White, modifier = Modifier.size(36.dp)) + } +} + /** The end of a row that has more pages */ @Composable fun MoreButton(loading: Boolean, horizontal: Boolean, onClick: () -> Unit) { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt index 9b97f55037e..3a3cb3f7378 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -72,7 +72,7 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDet singleLine = true, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp) + .padding(horizontal = PageGutter, vertical = 16.dp) .focusRequester(focus) .onPreviewKeyEvent { // Enter searches; handled here because desktop keyboards have no IME search action @@ -82,7 +82,7 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDet } else false }, ) - if (state.isSearching) LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) + if (state.isSearching) LinearProgressIndicator(Modifier.fillMaxWidth().padding(horizontal = PageGutter)) val found = state.results.filter { it.items.isNotEmpty() } val failed = state.results.filter { it.status is ProviderStatus.Failed && it.items.isEmpty() } diff --git a/shared/src/commonMain/composeResources/drawable/chevron_left.xml b/shared/src/commonMain/composeResources/drawable/chevron_left.xml new file mode 100644 index 00000000000..45a3ee07f53 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/chevron_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/chevron_right.xml b/shared/src/commonMain/composeResources/drawable/chevron_right.xml new file mode 100644 index 00000000000..0947af840a2 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt index db15b2018b2..bfb2a361e20 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt @@ -34,13 +34,17 @@ internal object CloudStreamPalette { val PrimaryDark = Color(0xFF3700B3) val Ongoing = Color(0xFFF53B66) - val DarkPrimaryGrayBg = Color(0xFF2B2C30) - val DarkBlackBg = Color(0xFF111111) - val DarkIconGrayBg = Color(0xFF1C1C20) - val DarkBoxItemBg = Color(0xFF161616) - val DarkText = Color(0xFFE9EAEE) - val DarkGrayText = Color(0xFF9BA0A4) - val DarkIcon = Color(0xFF9BA0A6) + // The desktop's default dark, after Netflix: neutral grays, the background #141414, raised + // surfaces a step lighter, and gray text #B3B3B3 that reads better than the Android app's + val DarkPrimaryGrayBg = Color(0xFF1F1F1F) + val DarkBlackBg = Color(0xFF141414) + val DarkIconGrayBg = Color(0xFF2A2A2A) + val DarkBoxItemBg = Color(0xFF181818) + val DarkText = Color(0xFFF2F2F2) + val DarkGrayText = Color(0xFFB3B3B3) + val DarkIcon = Color(0xFFB3B3B3) + /** The Android app's grayTextColor, which the other themes keep */ + val AndroidGrayText = Color(0xFF9BA0A4) // Amoled val AmoledBlack = Color(0xFF000000) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt index 20ddbba747e..5bd09398cf6 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt @@ -132,7 +132,7 @@ internal fun draculaScheme() = CloudStreamColorScheme( surfaceContainer = CloudStreamPalette.DraculaBoxItemBg, onBackground = CloudStreamPalette.DraculaText, // The Android theme keeps the standard grayTextColor - onSurfaceVariant = CloudStreamPalette.DarkGrayText, + onSurfaceVariant = CloudStreamPalette.AndroidGrayText, icon = CloudStreamPalette.DraculaIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, @@ -146,7 +146,7 @@ internal fun lavenderScheme() = CloudStreamColorScheme( surfaceContainer = CloudStreamPalette.LavenderBoxItemBg, onBackground = CloudStreamPalette.LavenderText, // The Android theme keeps the standard grayTextColor - onSurfaceVariant = CloudStreamPalette.DarkGrayText, + onSurfaceVariant = CloudStreamPalette.AndroidGrayText, icon = CloudStreamPalette.LavenderIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, @@ -160,7 +160,7 @@ internal fun silentBlueScheme() = CloudStreamColorScheme( surfaceContainer = CloudStreamPalette.SilentBlueBoxItemBg, onBackground = CloudStreamPalette.SilentBlueText, // The Android theme keeps the standard grayTextColor - onSurfaceVariant = CloudStreamPalette.DarkGrayText, + onSurfaceVariant = CloudStreamPalette.AndroidGrayText, icon = CloudStreamPalette.SilentBlueIcon, primary = CloudStreamPalette.Primary, ongoing = CloudStreamPalette.Ongoing, From 95c2cc4d232207c6c70e947fc07a2303291ee772 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 22:21:35 +0530 Subject: [PATCH 55/64] Desktop UI: a featured banner on Home, and a slim side bar - Home opens on the provider's first title, large: its backdrop faded into the page, its name, year, length, genres and the start of its story, and More info. The rows only have posters, so the rest loads from its page, the poster standing in until then - The side bar is 72 dp, in the page's colour, icons only: the current page is lit and marked with a bar, and each icon names its page on hover Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 118 ++++++++++++------ .../cloudstream4/browse/HomeScreen.kt | 75 +++++++++++ .../lagradost/cloudstream4/browse/Posters.kt | 5 +- 3 files changed, 160 insertions(+), 38 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 8d58229649c..6d038e7c306 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -1,5 +1,20 @@ package com.lagradost.cloudstream4 +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,9 +24,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -252,39 +265,7 @@ fun AppShell(navigator: AppNavigator) { // The mouse's back button goes back, as in a browser .onPointerEvent(PointerEventType.Press) { if (it.button == PointerButton.Back) navigator.back() }, ) { - // As the Android app's nav_rail_view: on primaryGrayBackground, the selected item in the - // primary colour (item_select_color.xml). Labels stay, there is room for them on a desktop - if (!hideRail) NavigationRail( - modifier = Modifier.fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - val itemColors = NavigationRailItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.primary, - selectedTextColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = MaterialTheme.colorScheme.onBackground, - unselectedTextColor = MaterialTheme.colorScheme.onBackground, - ) - Column( - modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - ) { - Destination.entries.forEach { destination -> - val selected = navigator.destination == destination - NavigationRailItem( - selected = selected, - onClick = { navigator.select(destination) }, - icon = { - Icon( - painterResource(if (selected) destination.selectedIcon else destination.icon), - contentDescription = null, - ) - }, - label = { Text(stringResource(destination.title)) }, - colors = itemColors, - ) - } - } - } + if (!hideRail) SlimRail(navigator) Box(Modifier.weight(1f).fillMaxHeight()) { if (page != null) { CompositionLocalProvider(LocalViewModelStoreOwner provides page) { @@ -386,3 +367,68 @@ private fun ComingSoon(destination: Destination) { ) } } + +/** + * The destinations down the left, slim and in the page's own colour, as streaming apps have it: + * icons only, their names on hover. The current one is lit and marked with a bar. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SlimRail(navigator: AppNavigator) { + Column( + Modifier.fillMaxHeight().width(72.dp).background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Destination.entries.forEach { destination -> + val selected = navigator.destination == destination + val title = stringResource(destination.title) + TooltipArea( + tooltip = { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 4.dp) { + Text(title, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) + } + }, + delayMillis = 300, + tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.CenterEnd, alignment = Alignment.CenterEnd, offset = DpOffset(8.dp, 0.dp)), + ) { + RailItem(destination, title, selected) { navigator.select(destination) } + } + } + } +} + +@Composable +private fun RailItem(destination: Destination, title: String, selected: Boolean, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val primary = MaterialTheme.colorScheme.primary + Box(Modifier.width(72.dp).height(48.dp), contentAlignment = Alignment.Center) { + if (selected) Box( + Modifier.align(Alignment.CenterStart).width(3.dp).height(24.dp) + .background(primary, RoundedCornerShape(topEnd = 3.dp, bottomEnd = 3.dp)), + ) + Box( + Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + when { + selected -> primary.copy(alpha = 0.16f) + hovered -> MaterialTheme.colorScheme.surfaceVariant + else -> Color.Transparent + }, + ) + .hoverable(interaction) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(if (selected) destination.selectedIcon else destination.icon), + contentDescription = title, + tint = if (selected) primary else MaterialTheme.colorScheme.onBackground, + ) + } + } +} + diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index edd53c8023c..aa18b32add1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -1,5 +1,20 @@ package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream4.generated.resources.info +import com.lagradost.cloudstream4.providers.loadSafely import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -48,6 +63,9 @@ fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit, openDetails if (state.providers.isNotEmpty()) TopBar(state, viewModel::onAction) // One list, so Continue watching scrolls away with the rows below it LazyColumn(contentPadding = PaddingValues(bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + // The provider's first title, large, as streaming apps open on one + val featured = (state.status as? HomeStatus.Done)?.let { state.rows.firstOrNull()?.items?.firstOrNull() } + if (featured != null) item(key = "featured") { FeaturedBanner(featured, openDetails) } // Above the provider's rows, so it is there even when the provider fails to load item { ContinueWatchingRow(DataStoreWatchStore.instance, openDetails, Modifier.padding(top = 8.dp)) } @@ -124,3 +142,60 @@ private fun TopBar(state: HomeState, onAction: (HomeAction) -> Unit) { } } } + +/** + * The provider's first title across the top of the page: its backdrop, faded into the page, with + * its name, a few facts and the start of its story. The rows only have its poster, so the rest + * loads from its page; the poster stands in until then. + */ +@Composable +private fun LazyItemScope.FeaturedBanner(item: SearchResponse, openDetails: (apiName: String, url: String) -> Unit) { + val details by produceState(null, item.apiName, item.url) { + val api = APIHolder.getApiFromNameNull(item.apiName) ?: return@produceState + value = (api.loadSafely(item.url) as? Resource.Success)?.value + } + val background = MaterialTheme.colorScheme.background + Box( + Modifier + .fillParentMaxWidth() + .heightIn(min = 340.dp, max = 600.dp) + .fillParentMaxHeight(0.62f) + .padding(bottom = 8.dp), + ) { + RemoteImage( + details?.backgroundPosterUrl ?: details?.posterUrl ?: item.posterUrl, + details?.posterHeaders ?: item.posterHeaders, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + corner = 0.dp, + ) + // Faded into the page below and to the left, where the text is + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0.45f to Color.Transparent, 1f to background))) + Box(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to background.copy(alpha = 0.92f), 0.55f to Color.Transparent))) + Column( + Modifier.align(Alignment.BottomStart).fillMaxWidth(0.5f).padding(start = PageGutter, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + details?.name ?: item.name, + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val facts = listOfNotNull( + details?.year?.toString(), + details?.duration?.let { "$it min" }, + details?.tags?.take(3)?.joinToString(", ")?.ifEmpty { null }, + ) + if (facts.isNotEmpty()) Text(facts.joinToString(" · "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + details?.plot?.let { Text(it, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis) } + Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WhiteButton(onClick = { openDetails(item.apiName, item.url) }) { + Icon(painterResource(Res.drawable.info), contentDescription = null) + Text("More info", modifier = Modifier.padding(start = 8.dp)) + } + } + } + } +} + diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt index ff80aeec454..1a792104c74 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.ImageLoader import coil3.PlatformContext @@ -98,7 +99,7 @@ private val horizontalWidth = 240.dp /** An image from a provider, which may need its own headers to serve it */ @Composable -fun RemoteImage(url: String?, headers: Map?, contentDescription: String?, modifier: Modifier = Modifier) { +fun RemoteImage(url: String?, headers: Map?, contentDescription: String?, modifier: Modifier = Modifier, corner: Dp = 8.dp) { val context = LocalPlatformContext.current val request = remember(url, headers) { ImageRequest.Builder(context) @@ -109,7 +110,7 @@ fun RemoteImage(url: String?, headers: Map?, contentDescription: }.build()) .build() } - Box(modifier.clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) { + Box(modifier.clip(RoundedCornerShape(corner)).background(MaterialTheme.colorScheme.surfaceVariant)) { AsyncImage( model = request, contentDescription = contentDescription, From 463327ccbc62b0cf14b3c175767068754ca33f14 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 22:51:28 +0530 Subject: [PATCH 56/64] Desktop UI: the dark theme in Prime Video colours, blue-black with a sky-blue accent Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lagradost/cloudstream4/theme/Palette.kt | 19 ++++++++++--------- .../lagradost/cloudstream4/theme/Scheme.kt | 3 ++- .../com/lagradost/cloudstream4/theme/Theme.kt | 2 ++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt index bfb2a361e20..abc2d22d446 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt @@ -34,15 +34,16 @@ internal object CloudStreamPalette { val PrimaryDark = Color(0xFF3700B3) val Ongoing = Color(0xFFF53B66) - // The desktop's default dark, after Netflix: neutral grays, the background #141414, raised - // surfaces a step lighter, and gray text #B3B3B3 that reads better than the Android app's - val DarkPrimaryGrayBg = Color(0xFF1F1F1F) - val DarkBlackBg = Color(0xFF141414) - val DarkIconGrayBg = Color(0xFF2A2A2A) - val DarkBoxItemBg = Color(0xFF181818) - val DarkText = Color(0xFFF2F2F2) - val DarkGrayText = Color(0xFFB3B3B3) - val DarkIcon = Color(0xFFB3B3B3) + // The desktop's default dark, after Prime Video: a blue-black background, slate surfaces a + // step lighter, blue-gray secondary text and a sky-blue accent + val DarkPrimary = Color(0xFF1A98FF) + val DarkPrimaryGrayBg = Color(0xFF1B2530) + val DarkBlackBg = Color(0xFF0F171E) + val DarkIconGrayBg = Color(0xFF252E39) + val DarkBoxItemBg = Color(0xFF16202A) + val DarkText = Color(0xFFF2F4F5) + val DarkGrayText = Color(0xFF8197A4) + val DarkIcon = Color(0xFFAAB8C2) /** The Android app's grayTextColor, which the other themes keep */ val AndroidGrayText = Color(0xFF9BA0A4) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt index 5bd09398cf6..12425a41b30 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt @@ -92,7 +92,7 @@ internal fun darkScheme() = CloudStreamColorScheme( onBackground = CloudStreamPalette.DarkText, onSurfaceVariant = CloudStreamPalette.DarkGrayText, icon = CloudStreamPalette.DarkIcon, - primary = CloudStreamPalette.Primary, + primary = CloudStreamPalette.DarkPrimary, ongoing = CloudStreamPalette.Ongoing, isLight = false, ) @@ -100,6 +100,7 @@ internal fun darkScheme() = CloudStreamColorScheme( /** AmoledMode in styles.xml: black, with iconGrayBackground left at primaryBlackBackground */ internal fun amoledScheme() = darkScheme().copy( + primary = CloudStreamPalette.Primary, background = CloudStreamPalette.AmoledBlack, surface = CloudStreamPalette.DarkBlackBg, surfaceVariant = CloudStreamPalette.AmoledBlack, diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt index e8ff2d235f7..ab722b330a7 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt @@ -66,6 +66,8 @@ fun modeToTheme(mode : CloudStreamThemeMode, primaryColor: CloudStreamPrimaryCol } when { mode == CloudStreamThemeMode.Dynamic -> base + // "Normal" is the theme's own accent, which is NORMAL's colour on all but the default dark + primaryColor == CloudStreamPrimaryColor.NORMAL -> base primaryColor == CloudStreamPrimaryColor.DYNAMIC -> base.copy(primary = dynamicPrimary) primaryColor == CloudStreamPrimaryColor.DYNAMIC_TWO -> base.copy(primary = dynamicSecondary) else -> base.copy(primary = primaryColor.color) From c9e7f1fd3443aaa6a937ea2174361cd84e98de11 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 23:17:11 +0530 Subject: [PATCH 57/64] Desktop UI: a floating glass dock at the bottom, and Continue watching plays at once from the last source Co-Authored-By: Claude Opus 5.5 (1M context) --- desktopApp/build.gradle.kts | 1 + .../com/lagradost/cloudstream4/AppShell.kt | 143 ++++++++++++------ .../cloudstream4/browse/DetailScreen.kt | 17 ++- .../cloudstream4/browse/DownloadsScreen.kt | 2 +- .../cloudstream4/browse/HomeScreen.kt | 12 +- .../cloudstream4/browse/LibraryScreen.kt | 8 +- .../lagradost/cloudstream4/browse/Posters.kt | 3 + .../cloudstream4/browse/SearchScreen.kt | 4 +- .../cloudstream4/player/PlayerScreen.kt | 8 + gradle/libs.versions.toml | 2 + .../cloudstream4/detail/DetailViewModel.kt | 20 ++- .../cloudstream4/library/WatchStore.kt | 13 ++ .../cloudstream4/player/LinksViewModel.kt | 14 +- .../library/DataStoreWatchStore.kt | 16 ++ .../cloudstream4/PlaybackViewModelTest.kt | 36 +++++ .../library/DataStoreWatchStoreTest.kt | 5 + 16 files changed, 238 insertions(+), 66 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 267052282f9..f0f82ab4f74 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { implementation(libs.kotlinx.collections.immutable) implementation(libs.coil.network.okhttp) implementation(libs.jna) + implementation(libs.haze) implementation(libs.okhttp) implementation(compose.desktop.currentOs) { // compose.desktop.currentOs imports the wrong material 2, so we exclude it diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 6d038e7c306..1018dce4107 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -1,6 +1,17 @@ package com.lagradost.cloudstream4 import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.border +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import com.lagradost.cloudstream4.browse.DockClearance +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.TooltipPlacement import androidx.compose.foundation.background @@ -10,7 +21,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -19,7 +29,6 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon @@ -109,12 +118,15 @@ enum class Destination( sealed class Page : ViewModelStoreOwner { override val viewModelStore = ViewModelStore() - class Details(val apiName: String, val url: String) : Page() + class Details(val apiName: String, val url: String) : Page() { + /** Play where the title was left as soon as it loads, once: Continue watching does */ + var autoPlay = false + } class Player(val request: PlayRequest) : Page() } /** - * Navigation for the desktop app: a rail on the left and the selected destination on the right, + * Navigation for the desktop app: the selected destination, with a dock floating at the bottom to switch, * with pages such as details and the player opened on top. * Ctrl+1 to Ctrl+5 switch destination, Ctrl+F opens Search, Escape, Alt+Left or the mouse's back * button goes back. M pauses the player and hides the app, see [hidden]. @@ -129,7 +141,7 @@ class AppNavigator { /** Details and player pages, last is shown */ val pages = mutableStateListOf() - /** The player fills the screen, without the rail or the window frame */ + /** The player fills the screen, without the window frame */ var fullscreen by mutableStateOf(false) /** @@ -149,6 +161,11 @@ class AppNavigator { pages.add(Page.Details(apiName, url)) } + /** Continue watching: plays the title where it was left, its page under the player to come back to */ + fun resume(apiName: String, url: String) { + pages.add(Page.Details(apiName, url).apply { autoPlay = true }) + } + fun play(request: PlayRequest) { pages.add(Page.Player(request)) } @@ -257,16 +274,18 @@ fun AppShell(navigator: AppNavigator) { ExtensionToasts(toasts) val page = navigator.pages.lastOrNull() - val hideRail = page is Page.Player && navigator.fullscreen + // The player has its own controls along the bottom, which the dock would cover + val showDock = page !is Page.Player + // What the dock blurs behind its glass + val glass = rememberHazeState() navigator.typing.Provide { - Row( + Box( Modifier .fillMaxSize() // The mouse's back button goes back, as in a browser .onPointerEvent(PointerEventType.Press) { if (it.button == PointerButton.Back) navigator.back() }, ) { - if (!hideRail) SlimRail(navigator) - Box(Modifier.weight(1f).fillMaxHeight()) { + Box(Modifier.fillMaxSize().hazeSource(glass)) { if (page != null) { CompositionLocalProvider(LocalViewModelStoreOwner provides page) { PageContent(page, navigator) { query -> @@ -280,18 +299,19 @@ fun AppShell(navigator: AppNavigator) { ?: remember(navigator) { SettingsHomeScreen(navigator::open) } val back: (() -> Unit)? = if (navigator.settingsStack.isEmpty()) null else ({ navigator.back() }) CompositionLocalProvider(LocalBackPress provides back) { - settingsPage.Content() + Box(Modifier.padding(bottom = DockClearance)) { settingsPage.Content() } } } - Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails) + Destination.Home -> HomeScreen(home, navigator::openExtensions, navigator::openDetails, navigator::resume) Destination.Search -> SearchScreen(search, navigator::openExtensions, navigator::openDetails) Destination.Library -> LibraryScreen(DataStoreWatchStore.instance, navigator::openDetails) Destination.Downloads -> DownloadsScreen(Downloads.instance, navigator::play, navigator::openDetails) else -> ComingSoon(navigator.destination) } - SnackbarHost(toasts, Modifier.align(Alignment.BottomCenter).padding(16.dp)) } + if (showDock) FloatingDock(navigator, glass, Modifier.align(Alignment.BottomCenter).padding(bottom = 20.dp)) + SnackbarHost(toasts, Modifier.align(Alignment.BottomCenter).padding(start = 16.dp, end = 16.dp, bottom = if (showDock) DockClearance else 16.dp)) } } } @@ -339,6 +359,8 @@ private fun PageContent(page: Page, navigator: AppNavigator, onSearch: (query: S onPlay = navigator::play, onOpen = navigator::openDetails, onSearch = onSearch, + autoPlay = page.autoPlay, + onAutoPlayed = { page.autoPlay = false }, ) is Page.Player -> PlayerScreen( @@ -369,66 +391,89 @@ private fun ComingSoon(destination: Destination) { } /** - * The destinations down the left, slim and in the page's own colour, as streaming apps have it: - * icons only, their names on hover. The current one is lit and marked with a bar. + * The destinations in a pill of frosted glass floating over the bottom of the page, as streaming + * apps have it: the page shows blurred through it. The current one is lit and named, the others + * are icons with their names on hover. */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun SlimRail(navigator: AppNavigator) { - Column( - Modifier.fillMaxHeight().width(72.dp).background(MaterialTheme.colorScheme.background), - verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, +private fun FloatingDock(navigator: AppNavigator, glass: HazeState, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(percent = 50) + val background = MaterialTheme.colorScheme.background + Row( + modifier + .shadow(24.dp, shape, ambientColor = Color.Black, spotColor = Color.Black) + .clip(shape) + .hazeEffect( + glass, + HazeStyle( + backgroundColor = background, + tint = HazeTint(background.copy(alpha = 0.55f)), + blurRadius = 28.dp, + noiseFactor = 0.06f, + ), + ) + // A light edge along the top, where light would catch the glass + .border( + 1.dp, + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.22f), Color.White.copy(alpha = 0.04f))), + shape, + ) + .padding(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, ) { Destination.entries.forEach { destination -> val selected = navigator.destination == destination val title = stringResource(destination.title) TooltipArea( tooltip = { - Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 4.dp) { + if (!selected) Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 4.dp) { Text(title, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) } }, delayMillis = 300, - tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.CenterEnd, alignment = Alignment.CenterEnd, offset = DpOffset(8.dp, 0.dp)), + tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.TopCenter, alignment = Alignment.TopCenter, offset = DpOffset(0.dp, (-8).dp)), ) { - RailItem(destination, title, selected) { navigator.select(destination) } + DockItem(destination, title, selected) { navigator.select(destination) } } } } } @Composable -private fun RailItem(destination: Destination, title: String, selected: Boolean, onClick: () -> Unit) { +private fun DockItem(destination: Destination, title: String, selected: Boolean, onClick: () -> Unit) { val interaction = remember { MutableInteractionSource() } val hovered by interaction.collectIsHoveredAsState() val primary = MaterialTheme.colorScheme.primary - Box(Modifier.width(72.dp).height(48.dp), contentAlignment = Alignment.Center) { - if (selected) Box( - Modifier.align(Alignment.CenterStart).width(3.dp).height(24.dp) - .background(primary, RoundedCornerShape(topEnd = 3.dp, bottomEnd = 3.dp)), - ) - Box( - Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background( - when { - selected -> primary.copy(alpha = 0.16f) - hovered -> MaterialTheme.colorScheme.surfaceVariant - else -> Color.Transparent - }, - ) - .hoverable(interaction) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - painterResource(if (selected) destination.selectedIcon else destination.icon), - contentDescription = title, - tint = if (selected) primary else MaterialTheme.colorScheme.onBackground, + Row( + Modifier + .height(48.dp) + .clip(RoundedCornerShape(percent = 50)) + .background( + when { + selected -> primary.copy(alpha = 0.22f) + hovered -> Color.White.copy(alpha = 0.08f) + else -> Color.Transparent + }, ) - } + .hoverable(interaction) + .clickable(onClick = onClick) + .animateContentSize() + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(if (selected) destination.selectedIcon else destination.icon), + contentDescription = title, + tint = if (selected) primary else MaterialTheme.colorScheme.onBackground, + ) + if (selected) Text( + title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + modifier = Modifier.padding(start = 8.dp, end = 4.dp), + ) } } - diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index 6226b29bf89..2d533a9a78f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -72,6 +73,9 @@ fun DetailScreen( onOpen: (apiName: String, url: String) -> Unit, /** Looks for the title in every extension, from the Search page */ onSearch: (query: String) -> Unit, + /** Play where the title was left as soon as it loads, from Continue watching */ + autoPlay: Boolean = false, + onAutoPlayed: () -> Unit = {}, downloads: Downloads = Downloads.instance, ) { val state by viewModel.state.collectAsState() @@ -85,7 +89,7 @@ fun DetailScreen( WhiteButton(onClick = { viewModel.onAction(DetailAction.Retry) }) { Text("Try again") } } - DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, onSearch, downloads) + DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, onSearch, downloads, autoPlay, onAutoPlayed) } } } @@ -98,6 +102,8 @@ private fun Details( onOpen: (apiName: String, url: String) -> Unit, onSearch: (query: String) -> Unit, downloads: Downloads, + autoPlay: Boolean, + onAutoPlayed: () -> Unit, ) { val response = state.response ?: return val season = state.seasons.getOrNull(state.selectedSeason) @@ -109,6 +115,13 @@ private fun Details( val file = id?.let(downloads::file) onPlay(if (file == null) request else request.copy(localFile = file.absolutePath, localSubtitles = localSubtitles(downloads, id))) } + // Once only, so coming back from the player leaves the page as it is + LaunchedEffect(Unit) { + if (!autoPlay) return@LaunchedEffect + onAutoPlayed() + val request = viewModel.resumeRequest() ?: return@LaunchedEffect + play(request, state.resume?.episode) + } fun downloadButton(episode: Episode?): @Composable () -> Unit = { val id = downloadId(state, episode) val request = downloadRequest(state, episode) @@ -133,7 +146,7 @@ private fun Details( ) } } - LazyColumn(contentPadding = PaddingValues(bottom = 32.dp), modifier = Modifier.fillMaxSize()) { + LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { item { Header( response = response, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt index 89f54f9bdff..cf022a6a0da 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt @@ -91,7 +91,7 @@ fun DownloadsScreen( Message(stringResource(Res.string.downloads_empty), "Use the download button on a movie or an episode to watch it offline.") return@Column } - LazyColumn(contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + LazyColumn(contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 16.dp, bottom = DockClearance), modifier = Modifier.fillMaxSize()) { titles.forEach { (title, episodes) -> item(key = "title-${title.id}") { TitleRow(title, episodes.size, episodes.sumOf { states[it.id]?.bytes ?: 0 }) { openDetails(title.apiName, title.url) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index aa18b32add1..453f8c9a9a5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -56,18 +56,24 @@ import org.jetbrains.compose.resources.painterResource /** The home page of one provider, picked from a menu at the top */ @Composable -fun HomeScreen(viewModel: HomeViewModel, openExtensions: () -> Unit, openDetails: (apiName: String, url: String) -> Unit) { +fun HomeScreen( + viewModel: HomeViewModel, + openExtensions: () -> Unit, + openDetails: (apiName: String, url: String) -> Unit, + /** Plays a Continue watching title where it was left */ + resume: (apiName: String, url: String) -> Unit, +) { val state by viewModel.state.collectAsState() Column(Modifier.fillMaxSize()) { if (state.providers.isNotEmpty()) TopBar(state, viewModel::onAction) // One list, so Continue watching scrolls away with the rows below it - LazyColumn(contentPadding = PaddingValues(bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { // The provider's first title, large, as streaming apps open on one val featured = (state.status as? HomeStatus.Done)?.let { state.rows.firstOrNull()?.items?.firstOrNull() } if (featured != null) item(key = "featured") { FeaturedBanner(featured, openDetails) } // Above the provider's rows, so it is there even when the provider fails to load - item { ContinueWatchingRow(DataStoreWatchStore.instance, openDetails, Modifier.padding(top = 8.dp)) } + item { ContinueWatchingRow(DataStoreWatchStore.instance, resume, Modifier.padding(top = 8.dp)) } // What fills the rest of the page while there are no rows fun LazyListScope.whole(content: @Composable () -> Unit) = item { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt index 1f045e020f9..c97e9c56b7b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/LibraryScreen.kt @@ -78,7 +78,7 @@ fun LibraryScreen(store: WatchStore, openDetails: (apiName: String, url: String) } else { LazyVerticalGrid( columns = GridCells.Adaptive(150.dp), - contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = 24.dp), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = DockClearance), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { @@ -108,10 +108,10 @@ private fun TitleCard(header: TitleHeader, modifier: Modifier = Modifier, onClic /** * The titles the user started, newest first, like the Android app's Continue watching row. Each is * a small card: the poster, the title, how far its episode got, and a button to drop it from the - * row. Opening one goes to its page, where Resume picks it up. + * row. Clicking one plays it where it was left, from the source it was last watched on. */ @Composable -fun ContinueWatchingRow(store: WatchStore, openDetails: (apiName: String, url: String) -> Unit, modifier: Modifier = Modifier) { +fun ContinueWatchingRow(store: WatchStore, resume: (apiName: String, url: String) -> Unit, modifier: Modifier = Modifier) { val version by store.version.collectAsState() val entries = remember(version) { store.resumeEntries().sortedByDescending { it.updateTime } } if (entries.isEmpty()) return @@ -123,7 +123,7 @@ fun ContinueWatchingRow(store: WatchStore, openDetails: (apiName: String, url: S ContinueCard( entry = entry, position = position, - onClick = { openDetails(entry.header.apiName, entry.header.url) }, + onClick = { resume(entry.header.apiName, entry.header.url) }, onRemove = { store.removeResume(entry.header.id) }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt index 1a792104c74..c71388e0598 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -123,6 +123,9 @@ fun RemoteImage(url: String?, headers: Map?, contentDescription: /** The space at the sides of the pages' rows */ val PageGutter = 32.dp +/** Room left under a page's last row, so the floating dock at the bottom does not cover it */ +val DockClearance = 104.dp + /** Grows a little under the mouse, as cards do in streaming apps, over its neighbours */ @Composable fun Modifier.hoverZoom(interaction: MutableInteractionSource, enabled: Boolean = true): Modifier { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt index 3a3cb3f7378..01ee9b10a5c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -107,7 +107,7 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDet ) else -> LazyColumn( - contentPadding = PaddingValues(bottom = 24.dp), + contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize(), ) { items(found) { row -> @@ -145,7 +145,7 @@ private fun SearchHistory( onRemove: (SearchHistoryEntry) -> Unit, onClear: () -> Unit, ) { - LazyColumn(contentPadding = PaddingValues(bottom = 24.dp), modifier = Modifier.fillMaxSize()) { + LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { item { Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) { Text("Recent searches", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 743b9794eb1..9ac4102f8ab 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -92,6 +92,7 @@ import com.lagradost.cloudstream4.generated.resources.ic_baseline_volume_up_24 import com.lagradost.cloudstream4.generated.resources.pause_24px import com.lagradost.cloudstream4.generated.resources.play_arrow_24px import com.lagradost.cloudstream4.library.DataStoreWatchStore +import com.lagradost.cloudstream4.library.SavedSource import com.lagradost.cloudstream4.library.saveProgress import com.lagradost.cloudstream4.theme.BlackButton import com.lagradost.cloudstream4.theme.WhiteButton @@ -315,6 +316,8 @@ fun PlayerScreen( } LaunchedEffect(player) { var ticks = 0 + // The link whose source was saved as the title's last, once it played a few seconds + var sourceSaved: String? = null while (true) { status = player.status() val last = playedOnLink[1] @@ -324,6 +327,11 @@ fun PlayerScreen( if (status.duration > 0 && status.position > 0 && playedOnLink[0] >= 3) { playedTo[0] = (status.position * 1000).toLong() playedTo[1] = (status.duration * 1000).toLong() + val link = currentState.selectedLink + if (tracking != null && viewModel.request.localFile == null && link != null && link.url != sourceSaved) { + sourceSaved = link.url + runCatching { DataStoreWatchStore.instance.setLastSource(tracking.header.id, SavedSource(link.name, link.quality)) } + } // Every 10 seconds if (++ticks % 20 == 0) saveProgress() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5a91067bd9..45afe47373f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ # https://docs.gradle.org/current/userguide/plugins.html#sec:version_catalog_plugin_application # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] +haze = "1.7.3" activity = "1.13.0" androidGradlePlugin = "9.1.1" animeDb = "1.0.2" @@ -77,6 +78,7 @@ versionCode = "68" versionName = "4.8.0" [libraries] +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity" } anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeDb" } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt index 53f2e9dbf30..e87cc3039bd 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/detail/DetailViewModel.kt @@ -20,6 +20,7 @@ import com.lagradost.cloudstream4.compose.StateContainer import com.lagradost.cloudstream4.library.InMemoryWatchStore import com.lagradost.cloudstream4.library.PlaybackPosition import com.lagradost.cloudstream4.library.PlaybackTracking +import com.lagradost.cloudstream4.library.SavedSource import com.lagradost.cloudstream4.library.TitleHeader import com.lagradost.cloudstream4.library.WatchIds import com.lagradost.cloudstream4.library.WatchStore @@ -42,6 +43,8 @@ data class PlayRequest( val localFile: String? = null, /** Subtitles saved beside [localFile], their url a file path */ val localSubtitles: List = emptyList(), + /** The source the title was last watched from, played as soon as it is found */ + val preferredSource: SavedSource? = null, ) /** Where Continue watching left off: an episode, or the movie when [episode] is null */ @@ -227,7 +230,22 @@ class DetailViewModel( startPositionMs = if (fromStart) 0 else current.progress[id]?.takeUnless { it.watched }?.positionMs ?: 0, ) } else null - return PlayRequest(api.name, response.name, episode?.let(::episodeLabel), data, tracking) + val source = current.titleId?.let(store::lastSource) + return PlayRequest(api.name, response.name, episode?.let(::episodeLabel), data, tracking, preferredSource = source) + } + + /** + * What Continue watching plays: the episode the title was left at, or the movie, from where it + * stopped. Null when there is nothing to pick up, for example an episode the provider dropped. + */ + fun resumeRequest(): PlayRequest? { + val current = state.value + val resume = current.resume + return when { + resume?.episode != null -> playRequest(resume.episode) + current.movieData != null -> playRequest() + else -> null + } } companion object { diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt index 82133c3b0f5..702751d2421 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/library/WatchStore.kt @@ -65,6 +65,9 @@ data class ResumeEntry( val updateTime: Long, ) +/** The source a title was last watched from, picked first when it plays again: its name and quality */ +data class SavedSource(val name: String, val quality: Int) + /** The library, playback positions and Continue watching, stored the way the Android app stores them */ interface WatchStore { /** Changes whenever anything is saved, so screens know to read again */ @@ -81,6 +84,10 @@ interface WatchStore { fun resumeEntries(): List fun setResume(entry: ResumeEntry) fun removeResume(parentId: Int) + + /** The source the title, by its id, last played from */ + fun lastSource(parentId: Int): SavedSource? + fun setLastSource(parentId: Int, source: SavedSource) } /** Kept in memory only, for tests and previews */ @@ -90,6 +97,7 @@ class InMemoryWatchStore(private val clock: () -> Long = { System.currentTimeMil private val bookmarks = LinkedHashMap() private val positions = HashMap() private val resumes = LinkedHashMap() + private val sources = HashMap() private fun changed() = _version.update { it + 1 } @@ -120,6 +128,11 @@ class InMemoryWatchStore(private val clock: () -> Long = { System.currentTimeMil resumes.remove(parentId) changed() } + + override fun lastSource(parentId: Int) = sources[parentId] + override fun setLastSource(parentId: Int, source: SavedSource) { + sources[parentId] = source + } } /** Shorter videos are not remembered, like on Android */ diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt index dc027aa6d61..fe5a578bdf5 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/player/LinksViewModel.kt @@ -61,7 +61,8 @@ sealed interface LinksAction { /** * Finds the links of one movie or episode and picks which to play: the best one once all links are * in, or the best so far when links keep coming after [autoPickAfter]. A link that fails to play - * moves on to the next best one. + * moves on to the next best one. The source the title was last watched from plays as soon as it + * comes, and is tried before the others. */ class LinksViewModel( /** Null when only a downloaded file plays and its extension is not loaded */ @@ -173,12 +174,19 @@ class LinksViewModel( else -> { // Torrents last: they take a while to start and share the user's connection val links = (links + link).sortedWith(compareBy { isTorrent(it) }.thenByDescending { rank(it.quality) }) - copy(links = links, selected = selected ?: if (autoPicking) nextLink(links, failed) else null) + val preferred = request.preferredSource?.takeIf { it.name == link.name && it.quality == link.quality && link.url !in failed } + copy(links = links, selected = selected ?: preferred?.let { link.url } ?: if (autoPicking) nextLink(links, failed) else null) } } } } + /** The best link not failed yet, one from the source last watched first */ + private fun nextLink(links: List, failed: Map): String? { + val left = links.filter { it.url !in failed } + return (left.firstOrNull { it.name == request.preferredSource?.name } ?: left.firstOrNull())?.url + } + private fun pickIfNone() { autoPicking = true updateState { if (selected != null) this else copy(selected = nextLink(links, failed)) } @@ -188,8 +196,6 @@ class LinksViewModel( /** The source name of a downloaded file */ const val LOCAL_SOURCE = "Downloaded" - private fun nextLink(links: List, failed: Map) = - links.firstOrNull { it.url !in failed }?.url fun isTorrent(link: ExtractorLink) = link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.MAGNET diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt index e55e97b754d..4795a28e30d 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStore.kt @@ -73,6 +73,13 @@ class DataStoreWatchStore( @JsonProperty("id") val id: Int, ) + /** Desktop's own, the Android app has no such key */ + @JsonIgnoreProperties(ignoreUnknown = true) + internal data class LastSource( + @JsonProperty("name") val name: String, + @JsonProperty("quality") val quality: Int, + ) + private fun BookmarkedData.toHeader(id: Int) = TitleHeader(id, apiName, url, name, type, posterUrl, posterHeaders, year, plot) override fun bookmark(id: Int): Bookmark? = synchronized(this) { @@ -144,6 +151,14 @@ class DataStoreWatchStore( changed() } + override fun lastSource(parentId: Int): SavedSource? = + context.getKey("$ACCOUNT/$LAST_SOURCE/$parentId", LastSource::class.java)?.let { SavedSource(it.name, it.quality) } + + // Not a change screens show, so no new version + override fun setLastSource(parentId: Int, source: SavedSource) { + context.setKey("$ACCOUNT/$LAST_SOURCE/$parentId", LastSource(source.name, source.quality)) + } + companion object { /** The Android app's first account, DataStoreHelper.currentAccount */ private const val ACCOUNT = "0" @@ -152,6 +167,7 @@ class DataStoreWatchStore( private const val POS_DUR = "video_pos_dur" private const val RESUME_WATCHING = "result_resume_watching_2" private const val HEADER_CACHE = "download_header_cache" + private const val LAST_SOURCE = "desktop_last_source" val instance: DataStoreWatchStore by lazy { DataStoreWatchStore() } } diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt index 3b0e7e50cae..8235280cae6 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/PlaybackViewModelTest.kt @@ -23,6 +23,7 @@ import com.lagradost.cloudstream4.detail.DetailStatus import com.lagradost.cloudstream4.detail.DetailViewModel import com.lagradost.cloudstream4.detail.PlayRequest import com.lagradost.cloudstream4.library.InMemoryWatchStore +import com.lagradost.cloudstream4.library.SavedSource import com.lagradost.cloudstream4.library.WatchType import com.lagradost.cloudstream4.library.saveProgress import com.lagradost.cloudstream4.player.HumanChecks @@ -46,6 +47,7 @@ import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** Serves a movie, a series, an anime and a torrent, and links for each, from memory */ private class FakeCatalog : MainAPI() { @@ -231,6 +233,36 @@ class PlaybackViewModelTest { assertEquals("a", vm.state.value.selectedLink?.name) } + private fun preferring(source: SavedSource, vararg links: Pair): LinksViewModel { + api.links = links.toList() + // Long enough that only the saved source can be picked before loading ends + return LinksViewModel(api, PlayRequest("Catalog", "A movie", null, "movie-data", preferredSource = source), 10.seconds) + } + + @Test + fun theSourceLastWatchedPlaysAsSoonAsItComes() { + api.gate = CompletableDeferred() + api.slowAfter = 2 + val vm = preferring(SavedSource("sd", 720), "hd" to 1080, "sd" to 720, "late" to 2160) + + // Picked while links are still coming, over a better one + val early = await(vm.state) { it.selected != null } + assertTrue(early.loading) + assertEquals("sd", early.selectedLink?.name) + api.gate!!.complete(Unit) + assertEquals("sd", await(vm.state) { !it.loading }.selectedLink?.name) + + // When it fails, the best of the rest plays + vm.onAction(LinksAction.Failed("https://example.invalid/sd.m3u8", "403")) + assertEquals("late", vm.state.value.selectedLink?.name) + } + + @Test + fun theSourceLastWatchedComesFirstInAnotherQuality() { + val vm = preferring(SavedSource("sd", 480), "hd" to 1080, "sd" to 720) + assertEquals("sd", await(vm.state) { !it.loading }.selectedLink?.name) + } + @Test fun aSlowExtractorDoesNotHoldBackPlayback() { api.gate = CompletableDeferred() @@ -337,6 +369,10 @@ class PlaybackViewModelTest { assertEquals(600_000, vm.state.value.resume!!.positionMs) assertEquals(0.5f, vm.state.value.progressOf(pilot)!!.fraction) assertEquals(600_000, vm.playRequest(pilot)!!.tracking!!.startPositionMs) + // Continue watching plays the same, from the source last watched + store.setLastSource(state.titleId!!, SavedSource("hd", 1080)) + assertEquals(vm.playRequest(pilot), vm.resumeRequest()) + assertEquals(SavedSource("hd", 1080), vm.resumeRequest()!!.preferredSource) // Nearly finished: Continue watching moves on to the next episode, which starts at the beginning store.saveProgress(tracking, positionMs = 1_150_000, durationMs = 1_200_000) diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt index bb757d5044f..b096e96a5b7 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/library/DataStoreWatchStoreTest.kt @@ -46,6 +46,11 @@ class DataStoreWatchStoreTest { while (System.currentTimeMillis() < deadline && !(Files.exists(file) && keys.all { it in file.readText() })) Thread.sleep(50) keys.forEach { assertTrue(it in file.readText(), "$it is saved under the Android app's key") } + // The source last watched, desktop's own key + assertNull(again.lastSource(42)) + again.setLastSource(42, SavedSource("Vidplay", 1080)) + assertEquals(SavedSource("Vidplay", 1080), DataStoreWatchStore().lastSource(42)) + again.setBookmark(header, WatchType.NONE) again.removeResume(42) assertNull(again.bookmark(42)) From 80a0fc827b67379833fb3547e765952626da62b5 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 23:26:19 +0530 Subject: [PATCH 58/64] Desktop UI: the Home banner shows the extension's title logo, score and age rating Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/browse/HomeScreen.kt | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index aa18b32add1..cf49fc78d17 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -1,6 +1,9 @@ package com.lagradost.cloudstream4.browse import androidx.compose.foundation.background +import coil3.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.lazy.LazyItemScope @@ -146,7 +149,9 @@ private fun TopBar(state: HomeState, onAction: (HomeAction) -> Unit) { /** * The provider's first title across the top of the page: its backdrop, faded into the page, with * its name, a few facts and the start of its story. The rows only have its poster, so the rest - * loads from its page; the poster stands in until then. + * loads from its page; the poster stands in until then. What the extension gives for it is used as + * the Android app's home preview does: the wide background picture, and the title's logo in place + * of its name, which comes back if the logo does not load. */ @Composable private fun LazyItemScope.FeaturedBanner(item: SearchResponse, openDetails: (apiName: String, url: String) -> Unit) { @@ -176,13 +181,25 @@ private fun LazyItemScope.FeaturedBanner(item: SearchResponse, openDetails: (api Modifier.align(Alignment.BottomStart).fillMaxWidth(0.5f).padding(start = PageGutter, bottom = 28.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - details?.name ?: item.name, + val name = details?.name ?: item.name + var logoFailed by remember(details?.logoUrl) { mutableStateOf(false) } + val logo = details?.logoUrl?.takeIf { it.isNotBlank() && !logoFailed } + if (logo != null) AsyncImage( + model = logo, + contentDescription = name, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + onError = { logoFailed = true }, + modifier = Modifier.fillMaxWidth(0.8f).height(120.dp), + ) else Text( + name, style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), maxLines = 2, overflow = TextOverflow.Ellipsis, ) val facts = listOfNotNull( + details?.score?.let { "★ ${"%.1f".format(it.toFloat(10))}" }, + details?.contentRating?.takeIf { it.isNotBlank() }, details?.year?.toString(), details?.duration?.let { "$it min" }, details?.tags?.take(3)?.joinToString(", ")?.ifEmpty { null }, From b3a80580573adc66e38f069ac1f891a455280e18 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Fri, 16 Oct 2026 23:44:00 +0530 Subject: [PATCH 59/64] Desktop UI: the theme on the window frame, the player and the settings header The window's title bar and border are painted in the theme's colours through DWM (exact colours on Windows 11, dark title bar on Windows 10), again whenever the theme changes. The player's bars and side panels use a dark theme's own colours, neutral greys under a light theme. The settings header takes the page's colour. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../kotlin/com/lagradost/cloudstream4/Main.kt | 14 ++++- .../lagradost/cloudstream4/WindowsTitleBar.kt | 56 +++++++++++++++++++ .../cloudstream4/player/PlayerScreen.kt | 17 ++++-- .../kotlin/com/mihon/material/AppBar.kt | 9 ++- 4 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsTitleBar.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt index 01aabebf62f..f7a80a634be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -103,7 +103,7 @@ fun main() { onKeyEvent = navigator::onUnhandledKeyEvent, ) { appWindow = window - MainContent(navigator) + MainContent(navigator, window) FullscreenEffect(navigator, windowState, window) LaunchedEffect(navigator.hidden) { if (WindowsHide.supported) { @@ -144,6 +144,7 @@ private fun askToStartFresh(): Boolean { resizable = false, ) { CloudStreamTheme { + ThemedFrame(window) Surface(color = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground) { Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("Welcome back", style = MaterialTheme.typography.titleLarge) @@ -193,13 +194,14 @@ private fun FullscreenEffect(navigator: AppNavigator, windowState: WindowState, } @Composable -private fun MainContent(navigator: AppNavigator) { +private fun MainContent(navigator: AppNavigator, window: java.awt.Window) { val ui = rememberAppSettings().ui // Changing the theme or color in settings applies right away val theme by ui.theme.changes().collectAsState(ui.theme.get()) val primaryColor by ui.primaryColor.changes().collectAsState(ui.primaryColor.get()) CloudStreamTheme(mode = desktopThemeMode(theme), primaryColor = desktopPrimaryColor(primaryColor)) { + ThemedFrame(window) CompositionLocalProvider(LocalFocusOutlineDefault provides false) { Surface( color = MaterialTheme.colorScheme.background, @@ -211,3 +213,11 @@ private fun MainContent(navigator: AppNavigator) { } } } + +/** The window's title bar and border in the theme's colours, again whenever the theme changes */ +@Composable +private fun ThemedFrame(window: java.awt.Window) { + val background = MaterialTheme.colorScheme.background + val text = MaterialTheme.colorScheme.onBackground + LaunchedEffect(window, background, text) { WindowsTitleBar.paint(window, background, text) } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsTitleBar.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsTitleBar.kt new file mode 100644 index 00000000000..2120e487550 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/WindowsTitleBar.kt @@ -0,0 +1,56 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.ptr.IntByReference +import com.sun.jna.win32.StdCallLibrary +import java.awt.Window + +/** The dwmapi function the title bar needs, see dwmapi.h */ +@Suppress("FunctionName") +private interface DwmApi : StdCallLibrary { + fun DwmSetWindowAttribute(hwnd: Pointer, attribute: Int, value: IntByReference, size: Int): Int +} + +private val dwmapi: DwmApi? by lazy { + if (!System.getProperty("os.name").startsWith("Windows")) return@lazy null + runCatching { Native.load("dwmapi", DwmApi::class.java) } + .onFailure { println("ERROR DwmApi: dwmapi could not be loaded: $it") } + .getOrNull() +} + +/** + * Paints the window's title bar and border, which Windows draws, in the theme's colours, so the + * frame matches the page inside it instead of staying white. Windows 11 takes the exact colours, + * Windows 10 only switches the title bar to dark for a dark theme. + */ +object WindowsTitleBar { + private const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + private const val DWMWA_BORDER_COLOR = 34 + private const val DWMWA_CAPTION_COLOR = 35 + private const val DWMWA_TEXT_COLOR = 36 + + fun paint(window: Window, background: Color, text: Color) { + // Also what shows for a moment where the window grows, before the page is drawn there again + window.background = java.awt.Color(background.toArgb(), false) + val dwm = dwmapi ?: return + val hwnd = Native.getWindowPointer(window) ?: return + fun set(attribute: Int, value: Int) = dwm.DwmSetWindowAttribute(hwnd, attribute, IntByReference(value), 4) + set(DWMWA_USE_IMMERSIVE_DARK_MODE, if (background.luminance() < 0.5f) 1 else 0) + set(DWMWA_CAPTION_COLOR, background.colorRef()) + set(DWMWA_BORDER_COLOR, background.colorRef()) + set(DWMWA_TEXT_COLOR, text.colorRef()) + } + + /** A COLORREF, 0x00BBGGRR */ + private fun Color.colorRef(): Int { + val argb = toArgb() + val r = argb shr 16 and 0xFF + val g = argb shr 8 and 0xFF + val b = argb and 0xFF + return (b shl 16) or (g shl 8) or r + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 9ac4102f8ab..819b905a67d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -655,12 +656,20 @@ private val speeds = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0) private fun speedLabel(speed: Double) = "%s×".format(if (speed % 1.0 == 0.0) speed.toInt().toString() else speed.toString()) -/** The player is always dark, whatever the app's theme, like most video players */ +/** + * The player is always dark, like most video players: in the colours of a dark theme, the same as + * the rest of the app, and in neutral greys under a light theme. + */ private object PlayerColors { - val bar = Color(0xFF111114) - val panel = Color(0xFF18181C) + private val themeDark: Boolean + @Composable @ReadOnlyComposable get() = MaterialTheme.colorScheme.background.luminance() < 0.5f + val bar: Color + @Composable @ReadOnlyComposable get() = if (themeDark) MaterialTheme.colorScheme.background else Color(0xFF111114) + val panel: Color + @Composable @ReadOnlyComposable get() = if (themeDark) MaterialTheme.colorScheme.surfaceContainer else Color(0xFF18181C) val content = Color.White - val secondary = Color.White.copy(alpha = 0.68f) + val secondary: Color + @Composable @ReadOnlyComposable get() = if (themeDark) MaterialTheme.colorScheme.onSurfaceVariant else Color.White.copy(alpha = 0.68f) val track = Color(0x28FFFFFF) val buffered = Color(0x38FFFFFF) val selected = Color.White.copy(alpha = 0.10f) diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt index e5444159553..dc516736c64 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt @@ -121,9 +121,12 @@ fun AppBar( title = titleContent, actions = actions, colors = TopAppBarDefaults.topAppBarColors( - containerColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceColorAtElevation( - elevation = if (isActionMode) 3.dp else 0.dp, - ), + // The page's own colour, so the bar is one with it, as in the rest of the app + containerColor = backgroundColor ?: if (isActionMode) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + MaterialTheme.colorScheme.background + }, ), scrollBehavior = scrollBehavior, ) From 30e394ef07a1069ec9dba2f187dc73b801a3eee6 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 17 Oct 2026 00:24:50 +0530 Subject: [PATCH 60/64] Desktop UI: a Prime-style light theme, settings cards, a glass Home bar and compact downloads - Light theme in the Prime colours (white, cool blue-gray, deeper sky blue); "Dark" is now the Prime navy and the default, the pure black one is named "Black"; the Normal accent swatch shows the theme's real accent - Settings: a readable centred width, each group as rounded cards in the theme's box colour, flat switches and a slider without step dots, the header the page's colour also when scrolled, the info line with its icon beside the text - Home: the provider and Reload in a floating glass pill like the dock, the provider list a rounded themed menu with the current one ticked and a filter box past 8 providers - Downloads: one compact card per title, a movie on a single line - Title page: the backdrop behind the header, a shadowed poster, the score, rounded episode lines; Search's box rounded; the glass shared by the dock and the Home bar, with a dark edge on light themes Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/AppShell.kt | 29 +-- .../cloudstream4/browse/DetailScreen.kt | 159 +++++++----- .../cloudstream4/browse/DownloadsScreen.kt | 232 ++++++++++++------ .../lagradost/cloudstream4/browse/Glass.kt | 113 +++++++++ .../cloudstream4/browse/HomeScreen.kt | 194 ++++++++++++--- .../cloudstream4/browse/SearchScreen.kt | 11 + .../cloudstream4/settings/DesktopSettings.kt | 13 +- .../cloudstream4/settings/DesktopTheme.kt | 1 + .../cloudstream4/DevicePreferenceStore.kt | 3 +- .../cloudstream4/theme/Components.kt | 37 +++ .../lagradost/cloudstream4/theme/Palette.kt | 16 +- .../lagradost/cloudstream4/theme/Scheme.kt | 2 +- .../com/lagradost/cloudstream4/theme/Theme.kt | 2 +- .../kotlin/com/mihon/material/AppBar.kt | 15 +- .../kotlin/com/mihon/material/Slider.kt | 13 +- .../settings/PreferenceScaffold.kt | 60 +++-- .../presentation/settings/PreferenceScreen.kt | 65 ++++- .../settings/widget/InfoWidget.kt | 8 +- .../settings/widget/PreferenceGroupHeader.kt | 8 +- .../settings/widget/SwitchPreferenceWidget.kt | 2 + 20 files changed, 733 insertions(+), 250 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Glass.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt index 1018dce4107..af5e77f45bd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/AppShell.kt @@ -2,14 +2,9 @@ package com.lagradost.cloudstream4 import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.border -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush import com.lagradost.cloudstream4.browse.DockClearance +import com.lagradost.cloudstream4.browse.glass import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.HazeStyle -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import androidx.compose.foundation.TooltipArea @@ -398,27 +393,9 @@ private fun ComingSoon(destination: Destination) { @OptIn(ExperimentalFoundationApi::class) @Composable private fun FloatingDock(navigator: AppNavigator, glass: HazeState, modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(percent = 50) - val background = MaterialTheme.colorScheme.background Row( modifier - .shadow(24.dp, shape, ambientColor = Color.Black, spotColor = Color.Black) - .clip(shape) - .hazeEffect( - glass, - HazeStyle( - backgroundColor = background, - tint = HazeTint(background.copy(alpha = 0.55f)), - blurRadius = 28.dp, - noiseFactor = 0.06f, - ), - ) - // A light edge along the top, where light would catch the glass - .border( - 1.dp, - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.22f), Color.White.copy(alpha = 0.04f))), - shape, - ) + .glass(glass, RoundedCornerShape(percent = 50)) .padding(6.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -453,7 +430,7 @@ private fun DockItem(destination: Destination, title: String, selected: Boolean, .background( when { selected -> primary.copy(alpha = 0.22f) - hovered -> Color.White.copy(alpha = 0.08f) + hovered -> MaterialTheme.colorScheme.onBackground.copy(alpha = 0.08f) else -> Color.Transparent }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index 2d533a9a78f..ed8338cdeed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -1,4 +1,10 @@ package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -20,7 +26,6 @@ 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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -79,10 +84,7 @@ fun DetailScreen( downloads: Downloads = Downloads.instance, ) { val state by viewModel.state.collectAsState() - Column(Modifier.fillMaxSize()) { - IconButton(onClick = onBack, modifier = Modifier.padding(8.dp)) { - Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back") - } + Box(Modifier.fillMaxSize()) { when (val status = state.status) { DetailStatus.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } is DetailStatus.Failed -> Message("Could not load this title", status.message) { @@ -91,6 +93,16 @@ fun DetailScreen( DetailStatus.Done -> Details(state, viewModel, onPlay, onOpen, onSearch, downloads, autoPlay, onAutoPlayed) } + // Over the backdrop, in a disc of the page's colour so it shows on any picture + IconButton( + onClick = onBack, + modifier = Modifier + .padding(12.dp) + .shadow(8.dp, CircleShape) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f), CircleShape), + ) { + Icon(painterResource(Res.drawable.arrow_back), contentDescription = "Back") + } } } @@ -168,12 +180,11 @@ private fun Details( Text( "${season.label} · ${season.episodes.size} episodes", style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp), + modifier = Modifier.padding(start = PageGutter, end = PageGutter, top = 16.dp, bottom = 8.dp), ) } items(season.episodes) { episode -> EpisodeRow(episode, state.progressOf(episode), downloadButton(episode)) { play(viewModel.playRequest(episode), episode) } - HorizontalDivider(Modifier.padding(horizontal = 24.dp)) } } @@ -208,62 +219,79 @@ private fun Header( /** The movie's download button, null for a series or a live stream */ download: (@Composable () -> Unit)?, ) { - Row(Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp)) { + val background = MaterialTheme.colorScheme.background + // The title's wide picture behind it, faded into the page below and on the side of the text, as on Home + Box(Modifier.fillMaxWidth()) { RemoteImage( - response.posterUrl, + response.backgroundPosterUrl?.takeIf { it.isNotBlank() } ?: response.posterUrl, response.posterHeaders, - contentDescription = response.name, - modifier = Modifier.width(200.dp).height(300.dp), + contentDescription = null, + modifier = Modifier.matchParentSize(), + corner = 0.dp, ) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(response.name, style = MaterialTheme.typography.headlineMedium) - val facts = listOfNotNull( - response.year?.toString(), - response.type.name, - response.duration?.let { "$it min" }, - response.contentRating, - response.apiName, + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to background.copy(alpha = 0.35f), 1f to background))) + Box(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to background.copy(alpha = 0.6f), 0.7f to background.copy(alpha = 0.2f)))) + Row( + Modifier.padding(start = PageGutter, end = PageGutter, top = 72.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(28.dp), + ) { + RemoteImage( + response.posterUrl, + response.posterHeaders, + contentDescription = response.name, + modifier = Modifier.width(200.dp).height(300.dp).shadow(20.dp, RoundedCornerShape(8.dp)), ) - Text(facts.joinToString(" · "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - response.tags?.takeIf { it.isNotEmpty() }?.let { - Text(it.joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - response.plot?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) - } - state.unsupported?.let { Text(it, color = MaterialTheme.colorScheme.error) } - if (state.unsupported == null && state.movieData == null && state.seasons.isEmpty()) { - Text("No episodes yet", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Row( - Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val resume = state.resume - if (state.unsupported == null) when { - // Continue watching left off in an episode - resume?.episode != null -> WhiteButton(onClick = { onResume(resume.episode!!) }) { - Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) - val at = if (resume.positionMs > 0) " at ${formatTime(resume.positionMs)}" else "" - Text("${if (resume.positionMs > 0) "Resume" else "Play"} ${resume.label}$at", modifier = Modifier.padding(start = 8.dp)) - } - - state.movieData != null -> { - WhiteButton(onClick = { onPlay(false) }) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(response.name, style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)) + val facts = listOfNotNull( + response.score?.let { "★ ${"%.1f".format(it.toFloat(10))}" }, + response.year?.toString(), + response.type.name, + response.duration?.let { "$it min" }, + response.contentRating, + response.apiName, + ) + Text(facts.joinToString(" · "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + response.tags?.takeIf { it.isNotEmpty() }?.let { + Text(it.joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + response.plot?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) + } + state.unsupported?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (state.unsupported == null && state.movieData == null && state.seasons.isEmpty()) { + Text("No episodes yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row( + Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val resume = state.resume + if (state.unsupported == null) when { + // Continue watching left off in an episode + resume?.episode != null -> WhiteButton(onClick = { onResume(resume.episode!!) }) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) - Text(resume?.let { "Resume at ${formatTime(it.positionMs)}" } ?: "Play", modifier = Modifier.padding(start = 8.dp)) + val at = if (resume.positionMs > 0) " at ${formatTime(resume.positionMs)}" else "" + Text("${if (resume.positionMs > 0) "Resume" else "Play"} ${resume.label}$at", modifier = Modifier.padding(start = 8.dp)) + } + + state.movieData != null -> { + WhiteButton(onClick = { onPlay(false) }) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + Text(resume?.let { "Resume at ${formatTime(it.positionMs)}" } ?: "Play", modifier = Modifier.padding(start = 8.dp)) + } + if (resume != null) TextButton(onClick = { onPlay(true) }) { Text("Start over") } } - if (resume != null) TextButton(onClick = { onPlay(true) }) { Text("Start over") } } + LibraryButton(state.bookmark, onBookmark) + // The same title from other extensions, when this one's links do not play + BlackButton(onClick = onSearch) { + Icon(painterResource(Res.drawable.search_icon), contentDescription = null) + Text("Search", modifier = Modifier.padding(start = 8.dp)) + } + download?.invoke() } - LibraryButton(state.bookmark, onBookmark) - // The same title from other extensions, when this one's links do not play - BlackButton(onClick = onSearch) { - Icon(painterResource(Res.drawable.search_icon), contentDescription = null) - Text("Search", modifier = Modifier.padding(start = 8.dp)) - } - download?.invoke() } } } @@ -325,16 +353,22 @@ private fun LibraryButton(current: WatchType, onSelect: (WatchType) -> Unit) { @Composable private fun EpisodeRow(episode: Episode, progress: PlaybackPosition?, download: @Composable () -> Unit, onClick: () -> Unit) { + // A rounded line that lights under the mouse, the pages' gutter on both sides Row( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 12.dp), + Modifier + .fillMaxWidth() + .padding(horizontal = PageGutter - 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onClick) + .padding(8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { if (episode.posterUrl != null) { - RemoteImage(episode.posterUrl, null, contentDescription = null, modifier = Modifier.width(160.dp).height(90.dp)) + RemoteImage(episode.posterUrl, null, contentDescription = null, modifier = Modifier.width(160.dp).height(90.dp), corner = 6.dp) } else { Box( - Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), + Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceContainer), contentAlignment = Alignment.Center, ) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) } } @@ -344,7 +378,14 @@ private fun EpisodeRow(episode: Episode, progress: PlaybackPosition?, download: Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) } if (progress != null && !progress.watched && progress.positionMs > 0) { - LinearProgressIndicator(progress = { progress.fraction }, modifier = Modifier.fillMaxWidth(0.5f).padding(top = 4.dp)) + LinearProgressIndicator( + progress = { progress.fraction }, + modifier = Modifier.fillMaxWidth(0.5f).padding(top = 4.dp).height(3.dp), + trackColor = MaterialTheme.colorScheme.surface, + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + drawStopIndicator = {}, + ) } } when { diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt index cf022a6a0da..593b1735e6e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DownloadsScreen.kt @@ -1,5 +1,13 @@ package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import org.jetbrains.compose.resources.DrawableResource import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,7 +25,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -71,10 +78,14 @@ fun DownloadsScreen( val titles = remember(ids) { downloads.titles().map { it to downloads.episodes(it.id) } } val folder = downloads.folder - Column(Modifier.fillMaxSize()) { - Row(Modifier.fillMaxWidth().padding(start = 24.dp, top = 24.dp, end = 24.dp), verticalAlignment = Alignment.CenterVertically) { + // A readable width, centred, as on the settings pages + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Row( + Modifier.widthIn(max = DownloadsMaxWidth).fillMaxWidth().padding(start = 24.dp, top = 24.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Column(Modifier.weight(1f)) { - Text("Downloads", style = MaterialTheme.typography.headlineSmall) + Text("Downloads", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold)) val used = states.values.sumOf { it.bytes } Text( listOfNotNull(folder.absolutePath, used.takeIf { it > 0 }?.let { "${formatBytes(it)} used" }).joinToString(" · "), @@ -91,89 +102,168 @@ fun DownloadsScreen( Message(stringResource(Res.string.downloads_empty), "Use the download button on a movie or an episode to watch it offline.") return@Column } - LazyColumn(contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 16.dp, bottom = DockClearance), modifier = Modifier.fillMaxSize()) { - titles.forEach { (title, episodes) -> - item(key = "title-${title.id}") { - TitleRow(title, episodes.size, episodes.sumOf { states[it.id]?.bytes ?: 0 }) { openDetails(title.apiName, title.url) } - } - items(episodes, key = { "episode-${it.id}" }) { episode -> - val state = states[episode.id] ?: return@items - EpisodeDownloadRow( - label = downloadLabel(title, episode), - state = state, - onPlay = { downloadedPlayRequest(downloads, title, episode)?.let(onPlay) }, - onPause = { downloads.pause(episode.id) }, - onResume = when { - // Another source is picked on the title's page - state.pickAgain -> ({ openDetails(title.apiName, title.url) }) - downloads.canResume(title.id, episode.id) -> ({ downloads.resume(title.id, episode.id) }) - else -> null - }, - onDelete = { downloads.delete(title.id, episode.id) }, - ) - } - item(key = "divider-${title.id}") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + LazyColumn( + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 16.dp, bottom = DockClearance), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + items(titles, key = { (title, _) -> "title-${title.id}" }) { (title, episodes) -> + fun actions(episode: DownloadedEpisode, state: DownloadState) = DownloadActions( + state = state, + onPlay = { downloadedPlayRequest(downloads, title, episode)?.let(onPlay) }, + onPause = { downloads.pause(episode.id) }, + onResume = when { + // Another source is picked on the title's page + state.pickAgain -> ({ openDetails(title.apiName, title.url) }) + downloads.canResume(title.id, episode.id) -> ({ downloads.resume(title.id, episode.id) }) + else -> null + }, + onDelete = { downloads.delete(title.id, episode.id) }, + ) + DownloadCard( + title = title, + episodes = episodes.mapNotNull { episode -> states[episode.id]?.let { episode to it } }, + label = { downloadLabel(title, it) }, + actions = ::actions, + onOpen = { openDetails(title.apiName, title.url) }, + ) } } } } +/** How wide the list of downloads grows */ +private val DownloadsMaxWidth = 960.dp + +/** What can be done with one file, its buttons and the click on its line */ +private class DownloadActions( + val state: DownloadState, + val onPlay: () -> Unit, + val onPause: () -> Unit, + val onResume: (() -> Unit)?, + val onDelete: () -> Unit, +) + +/** + * One title as a compact card. A movie is a single line: its poster, name, status and buttons. A + * series has a short line for each episode under its name. + */ @Composable -private fun TitleRow(title: DownloadedTitle, count: Int, bytes: Long, onClick: () -> Unit) { - Row( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, +private fun DownloadCard( + title: DownloadedTitle, + episodes: List>, + label: (DownloadedEpisode) -> String, + actions: (DownloadedEpisode, DownloadState) -> DownloadActions, + onOpen: () -> Unit, +) { + val movie = episodes.singleOrNull()?.takeIf { it.first.episode == null } + Column( + Modifier + .widthIn(max = DownloadsMaxWidth - 48.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), ) { - RemoteImage(title.poster, null, contentDescription = title.name, modifier = Modifier.width(60.dp).height(90.dp)) - Column(Modifier.weight(1f)) { - Text(title.name, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) - val files = if (count == 1) "1 file" else "$count files" - Text( - listOf(title.type.name, title.apiName, files, formatBytes(bytes)).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row( + Modifier.fillMaxWidth().clickable(onClick = onOpen).padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RemoteImage(title.poster, null, contentDescription = title.name, modifier = Modifier.width(44.dp).height(66.dp), corner = 6.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + title.name, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val bytes = episodes.sumOf { it.second.bytes } + val files = if (episodes.size == 1) null else "${episodes.size} files" + Text( + listOfNotNull(title.type.name, title.apiName, files, formatBytes(bytes)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (movie != null) DownloadStatusLine(movie.second) + } + if (movie != null) DownloadButtons(actions(movie.first, movie.second)) } + if (movie == null) episodes.forEach { (episode, state) -> + val action = actions(episode, state) + val done = state.status == DownloadStatus.Done + // Lined up with the title's name, under the poster's column + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = done, onClick = action.onPlay) + .padding(start = 68.dp, end = 10.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label(episode), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + DownloadStatusLine(state) + } + DownloadButtons(action) + } + } + if (movie == null) Spacer(Modifier.height(6.dp)) } } +/** The status in a line of small text, and a thin bar under it while the file downloads or is paused */ @Composable -private fun EpisodeDownloadRow( - label: String, - state: DownloadState, - onPlay: () -> Unit, - onPause: () -> Unit, - onResume: (() -> Unit)?, - onDelete: () -> Unit, -) { - val done = state.status == DownloadStatus.Done - Row( - Modifier.fillMaxWidth().clickable(enabled = done, onClick = onPlay).padding(start = 76.dp, top = 6.dp, bottom = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - statusText(state), - style = MaterialTheme.typography.bodySmall, - color = if (state.status == DownloadStatus.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, +private fun DownloadStatusLine(state: DownloadState) { + Text( + statusText(state), + style = MaterialTheme.typography.bodySmall, + color = if (state.status == DownloadStatus.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (state.active || state.status == DownloadStatus.Paused) { + val color = MaterialTheme.colorScheme.primary + val track = MaterialTheme.colorScheme.surface + val bar = Modifier.padding(top = 3.dp).fillMaxWidth(0.6f).height(3.dp) + val fraction = state.fraction + if (fraction != null) { + LinearProgressIndicator( + progress = { fraction }, + modifier = bar, + color = color, + trackColor = track, + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + drawStopIndicator = {}, ) - if (state.active || state.status == DownloadStatus.Paused) { - val fraction = state.fraction - if (fraction != null) LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f)) - else if (state.active) LinearProgressIndicator(modifier = Modifier.fillMaxWidth(0.6f)) - } + } else if (state.active) { + LinearProgressIndicator(modifier = bar, color = color, trackColor = track, strokeCap = StrokeCap.Round, gapSize = 0.dp) } + } +} + +/** Play, pause or resume, then delete, small enough to sit on a line of text */ +@Composable +private fun DownloadButtons(actions: DownloadActions) { + val state = actions.state + val resume = actions.onResume + Row { when { - done -> IconButton(onClick = onPlay) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = "Play") } - state.active -> IconButton(onClick = onPause) { Icon(painterResource(Res.drawable.pause_24px), contentDescription = "Pause") } - onResume != null -> IconButton(onClick = onResume) { Icon(painterResource(Res.drawable.downloading_24px), contentDescription = "Resume") } + state.status == DownloadStatus.Done -> SmallIconButton(Res.drawable.play_arrow_24px, "Play", actions.onPlay) + state.active -> SmallIconButton(Res.drawable.pause_24px, "Pause", actions.onPause) + resume != null -> SmallIconButton(Res.drawable.downloading_24px, "Resume", resume) } - IconButton(onClick = onDelete) { Icon(painterResource(Res.drawable.delete_24px), contentDescription = "Delete") } + SmallIconButton(Res.drawable.delete_24px, "Delete", actions.onDelete) + } +} + +@Composable +private fun SmallIconButton(icon: DrawableResource, description: String, onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(36.dp)) { + Icon(painterResource(icon), contentDescription = description, modifier = Modifier.size(20.dp)) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Glass.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Glass.kt new file mode 100644 index 00000000000..1163815386e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Glass.kt @@ -0,0 +1,113 @@ +package com.lagradost.cloudstream4.browse + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect + +/** + * Frosted glass in the theme's colours, floating over the page: what is behind it shows blurred + * through, as on the dock and the Home page's bar. [state] is what it blurs, marked with hazeSource. + */ +@Composable +fun Modifier.glass(state: HazeState, shape: Shape): Modifier { + val background = MaterialTheme.colorScheme.background + val light = background.luminance() > 0.5f + // A light edge along the top, where light would catch the glass; on a light theme a faint dark + // one, as a white edge would not show + val edge = if (light) { + listOf(Color.Black.copy(alpha = 0.10f), Color.Black.copy(alpha = 0.05f)) + } else { + listOf(Color.White.copy(alpha = 0.22f), Color.White.copy(alpha = 0.04f)) + } + val shadow = Color.Black.copy(alpha = if (light) 0.35f else 1f) + return this + .shadow(if (light) 16.dp else 24.dp, shape, ambientColor = shadow, spotColor = shadow) + .clip(shape) + .hazeEffect( + state, + HazeStyle( + backgroundColor = background, + tint = HazeTint(background.copy(alpha = if (light) 0.65f else 0.55f)), + blurRadius = 28.dp, + noiseFactor = 0.06f, + ), + ) + .border(1.dp, Brush.verticalGradient(edge), shape) +} + +/** + * A button on glass: a pill, or a circle when [round], lit under the mouse and while [active], such + * as while the menu it opens is open + */ +@Composable +fun GlassButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + active: Boolean = false, + round: Boolean = false, + content: @Composable RowScope.() -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val onBackground = MaterialTheme.colorScheme.onBackground + Row( + modifier + .height(44.dp) + .then(if (round) Modifier.width(44.dp) else Modifier) + .clip(RoundedCornerShape(percent = 50)) + .background( + when { + active -> onBackground.copy(alpha = 0.12f) + hovered && enabled -> onBackground.copy(alpha = 0.08f) + else -> Color.Transparent + }, + ) + .hoverable(interaction) + .clickable(enabled = enabled, onClick = onClick) + .then(if (round) Modifier else Modifier.padding(horizontal = 16.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + CompositionLocalProvider(LocalContentColor provides onBackground.copy(alpha = if (enabled) 1f else 0.6f)) { content() } + } +} + +/** The name of a button on glass, under the mouse */ +@Composable +fun GlassTooltip(text: String) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 4.dp) { + Text(text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt index ad1d38115ae..05566bf4b1d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/HomeScreen.kt @@ -1,6 +1,28 @@ package com.lagradost.cloudstream4.browse import androidx.compose.foundation.background +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.DpOffset +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import coil3.compose.AsyncImage import androidx.compose.ui.layout.ContentScale import androidx.compose.foundation.layout.height @@ -36,7 +58,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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 @@ -48,12 +69,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lagradost.cloudstream4.library.DataStoreWatchStore import com.lagradost.cloudstream4.generated.resources.Res -import com.lagradost.cloudstream4.generated.resources.arrow_downward +import com.lagradost.cloudstream4.generated.resources.autorenew_24px +import com.lagradost.cloudstream4.generated.resources.check +import com.lagradost.cloudstream4.generated.resources.chevron_right +import com.lagradost.cloudstream4.generated.resources.search_icon import com.lagradost.cloudstream4.home.HomeAction import com.lagradost.cloudstream4.home.HomeState import com.lagradost.cloudstream4.home.HomeStatus import com.lagradost.cloudstream4.home.HomeViewModel -import com.lagradost.cloudstream4.theme.BlackButton import com.lagradost.cloudstream4.theme.WhiteButton import org.jetbrains.compose.resources.painterResource @@ -67,13 +90,19 @@ fun HomeScreen( resume: (apiName: String, url: String) -> Unit, ) { val state by viewModel.state.collectAsState() + // What the bar at the top blurs behind its glass + val glass = rememberHazeState() + // The provider's first title, large, as streaming apps open on one + val featured = (state.status as? HomeStatus.Done)?.let { state.rows.firstOrNull()?.items?.firstOrNull() } + val showBar = state.providers.isNotEmpty() - Column(Modifier.fillMaxSize()) { - if (state.providers.isNotEmpty()) TopBar(state, viewModel::onAction) - // One list, so Continue watching scrolls away with the rows below it - LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { - // The provider's first title, large, as streaming apps open on one - val featured = (state.status as? HomeStatus.Done)?.let { state.rows.firstOrNull()?.items?.firstOrNull() } + Box(Modifier.fillMaxSize()) { + // One list, so Continue watching scrolls away with the rows below it. The bar floats over the + // banner; without one, the list starts below the bar + LazyColumn( + contentPadding = PaddingValues(top = if (showBar && featured == null) HomeBarClearance else 0.dp, bottom = DockClearance), + modifier = Modifier.fillMaxSize().hazeSource(glass), + ) { if (featured != null) item(key = "featured") { FeaturedBanner(featured, openDetails) } // Above the provider's rows, so it is there even when the provider fails to load item { ContinueWatchingRow(DataStoreWatchStore.instance, resume, Modifier.padding(top = 8.dp)) } @@ -113,41 +142,148 @@ fun HomeScreen( } } } + if (showBar) HomeBar(state, viewModel::onAction, glass, Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) } } +/** The room the bar takes at the top of the page, with the space around it */ +private val HomeBarClearance = 84.dp + +/** + * A pill of frosted glass floating at the top, as the dock does at the bottom: the provider, which + * opens the list of the others, and Reload, which turns while the page loads. + */ +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun TopBar(state: HomeState, onAction: (HomeAction) -> Unit) { +private fun HomeBar(state: HomeState, onAction: (HomeAction) -> Unit, glass: HazeState, modifier: Modifier = Modifier) { var menuOpen by remember { mutableStateOf(false) } + val loading = state.status == HomeStatus.Loading Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + modifier.glass(glass, RoundedCornerShape(percent = 50)).padding(6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Box { - TextButton(onClick = { menuOpen = true }) { - Text(state.selected ?: "", style = MaterialTheme.typography.titleLarge) + GlassButton(onClick = { menuOpen = true }, active = menuOpen, modifier = Modifier.widthIn(max = 360.dp)) { + Text( + state.selected ?: "", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) Icon( - painterResource(Res.drawable.arrow_downward), + painterResource(Res.drawable.chevron_right), contentDescription = "Choose provider", - modifier = Modifier.padding(start = 4.dp).size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 6.dp).size(20.dp).rotate(if (menuOpen) 270f else 90f), ) } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - state.providers.forEach { name -> - DropdownMenuItem( - text = { Text(name) }, - onClick = { - menuOpen = false - onAction(HomeAction.SelectProvider(name)) - }, - ) - } + ProviderMenu( + expanded = menuOpen, + providers = state.providers, + selected = state.selected, + onDismiss = { menuOpen = false }, + onSelect = { + menuOpen = false + onAction(HomeAction.SelectProvider(it)) + }, + ) + } + // A hairline between the two + Box(Modifier.size(1.dp, 22.dp).background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.14f))) + TooltipArea( + tooltip = { GlassTooltip("Reload") }, + delayMillis = 300, + tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.BottomCenter, alignment = Alignment.BottomCenter, offset = DpOffset(0.dp, 8.dp)), + ) { + val turn = rememberInfiniteTransition() + val angle by turn.animateFloat(0f, 360f, infiniteRepeatable(tween(900, easing = LinearEasing))) + GlassButton(onClick = { onAction(HomeAction.Reload) }, enabled = !loading, round = true) { + Icon( + painterResource(Res.drawable.autorenew_24px), + contentDescription = "Reload", + modifier = Modifier.size(20.dp).rotate(if (loading) angle else 0f), + ) } } - Box(Modifier.weight(1f)) - BlackButton(onClick = { onAction(HomeAction.Reload) }, enabled = state.status != HomeStatus.Loading) { - Text("Reload") + } +} + +/** + * The providers to pick from, in a rounded card of the theme's colours, the current one ticked. With + * many, a box at the top narrows them down as you type. + */ +@Composable +private fun ProviderMenu( + expanded: Boolean, + providers: List, + selected: String?, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { + var query by remember(expanded) { mutableStateOf("") } + val colors = MaterialTheme.colorScheme + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + offset = DpOffset(0.dp, 10.dp), + shape = RoundedCornerShape(16.dp), + containerColor = colors.surfaceContainer, + tonalElevation = 0.dp, + shadowElevation = 16.dp, + border = BorderStroke(1.dp, colors.onBackground.copy(alpha = 0.08f)), + modifier = Modifier.widthIn(min = 260.dp).heightIn(max = 480.dp), + ) { + if (providers.size > 8) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + BasicTextField( + value = query, + onValueChange = { query = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = colors.onBackground), + cursorBrush = SolidColor(colors.primary), + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 6.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.surface) + .focusRequester(focus), + decorationBox = { field -> + Row(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(Res.drawable.search_icon), contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Box(Modifier.padding(start = 10.dp)) { + if (query.isEmpty()) Text("Find a provider", style = MaterialTheme.typography.bodyMedium, color = colors.onSurfaceVariant) + field() + } + } + }, + ) + } + val shown = providers.filter { it.contains(query.trim(), ignoreCase = true) } + if (shown.isEmpty()) Text( + "No provider called \"${query.trim()}\"", + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + shown.forEach { name -> + val current = name == selected + DropdownMenuItem( + text = { + Text( + name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal), + color = if (current) colors.primary else colors.onBackground, + ) + }, + trailingIcon = if (current) { + { Icon(painterResource(Res.drawable.check), contentDescription = "Current", tint = colors.primary, modifier = Modifier.size(18.dp)) } + } else null, + onClick = { onSelect(name) }, + modifier = Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)), + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt index 01ee9b10a5c..8b337735fed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/SearchScreen.kt @@ -1,4 +1,7 @@ package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.ui.graphics.Color import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -70,6 +73,14 @@ fun SearchScreen(viewModel: SearchViewModel, openExtensions: () -> Unit, openDet } }, singleLine = true, + // A rounded box filled with the theme's box colour, outlined in the accent only while typing + shape = RoundedCornerShape(percent = 50), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + ), modifier = Modifier .fillMaxWidth() .padding(horizontal = PageGutter, vertical = 16.dp) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt index 9a666d8a274..1ba8eda3027 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopSettings.kt @@ -197,10 +197,13 @@ object SettingsLayoutScreen : SearchableSettings { @Composable override fun getTitleRes(): String = stringResource(Res.string.category_ui) - /** Same values and names as R.array.themes_names_values, without Material You which needs Android 12 */ + /** + * The values of R.array.themes_names_values, without Material You which needs Android 12. The + * names are the desktop's: "Black" is its Prime Video dark, the default, and "Light" the same in white + */ private val themes = listOf( - "AmoledLight" to "Dark", "Black" to "Gray", "Amoled" to "Amoled", "Light" to "Flashbang", - "System" to "System", "Dracula" to "Dracula", "Lavender" to "Lavender Dreams", "SilentBlue" to "Silent Blue", + "Black" to "Dark", "Light" to "Light", "System" to "System", "AmoledLight" to "Black", "Amoled" to "Amoled", + "Dracula" to "Dracula", "Lavender" to "Lavender Dreams", "SilentBlue" to "Silent Blue", ) /** Same values as R.array.themes_overlay_names_values, without the Material You colors */ @@ -248,6 +251,7 @@ object SettingsLayoutScreen : SearchableSettings { @Composable override fun getPreferences(): List { val settings = rememberAppSettings() + val theme by settings.ui.theme.changes().collectAsState(settings.ui.theme.get()) return persistentListOf( Preference.PreferenceGroup( @@ -259,7 +263,8 @@ object SettingsLayoutScreen : SearchableSettings { icon = painterResource(Res.drawable.colors_24px), title = stringResource(Res.string.primary_color_settings), entries = primaryColors.toMap(), - iconProvider = { key, _ -> RoundColor(desktopPrimaryColor(key).color) }, + // "Normal" is the theme's own accent + iconProvider = { key, _ -> RoundColor(modeToTheme(desktopThemeMode(theme), desktopPrimaryColor(key)).primary) }, ), Preference.PreferenceItem.ListPreference( preference = settings.ui.theme, diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt index ded34436d9e..10e7c77ab69 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/settings/DesktopTheme.kt @@ -13,6 +13,7 @@ fun desktopThemeMode(value: String?): CloudStreamThemeMode = when (value) { "Dracula" -> CloudStreamThemeMode.Dracula "Lavender" -> CloudStreamThemeMode.Lavender "SilentBlue" -> CloudStreamThemeMode.SilentBlue + // "Black" and the default: the Prime Video dark else -> CloudStreamThemeMode.Dark } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index f71545d9117..91c9f4c1a14 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -63,7 +63,8 @@ class UIPreferences(preferences: PreferenceStore) { "primary_color_key", "Normal" ) val theme = preferences.getString( - "app_theme_key", "AmoledLight" + // The desktop's Prime Video dark, see desktopThemeMode + "app_theme_key", "Black" ) val layout = preferences.getInt( "app_layout_key", -1 diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt index d87c4d50d51..16ca679a1f7 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt @@ -10,6 +10,10 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -119,3 +123,36 @@ fun AppFilterChip( ), ) } + +/** + * Switches and sliders in the theme's colours, flat as in streaming apps: Material's own have an + * outline round an off switch, and dots along a slider for every step. + */ +object AppControls { + @Composable + fun switchColors(): SwitchColors { + val colors = MaterialTheme.colorScheme + return SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = colors.primary, + checkedBorderColor = Color.Transparent, + checkedIconColor = colors.primary, + // iconGrayBackground, a step off the page + uncheckedThumbColor = colors.onSurfaceVariant, + uncheckedTrackColor = colors.surface, + uncheckedBorderColor = Color.Transparent, + ) + } + + @Composable + fun sliderColors(): SliderColors { + val colors = MaterialTheme.colorScheme + return SliderDefaults.colors( + thumbColor = colors.primary, + activeTrackColor = colors.primary, + inactiveTrackColor = colors.surface, + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt index abc2d22d446..ad32f267c38 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt @@ -52,14 +52,16 @@ internal object CloudStreamPalette { /** amoledModeLight in colors.xml */ val AmoledNearBlack = Color(0xFF121213) - // Light - val LightPrimaryGrayBg = Color(0xFFF1F1F1) + // Light, the Prime Video dark turned over: a white page, cool blue-gray surfaces, the dark + // theme's blue-black for text and a deeper sky blue that reads on white + val LightPrimary = Color(0xFF0B7AD6) + val LightPrimaryGrayBg = Color(0xFFEEF2F5) val LightBlackBg = Color(0xFFFFFFFF) - val LightIconGrayBg = Color(0xFFEEEEEE) - val LightBoxItemBg = Color(0xFFEEEEEE) - val LightText = Color(0xFF202125) - val LightGrayText = Color(0xFF5F6267) - val LightIcon = Color(0xFF5F6267) + val LightIconGrayBg = Color(0xFFE3E9EE) + val LightBoxItemBg = Color(0xFFF3F6F8) + val LightText = Color(0xFF0F171E) + val LightGrayText = Color(0xFF5A6B78) + val LightIcon = Color(0xFF3E4F5C) // Dracula val DraculaPrimaryGrayBg = Color(0xFF414450) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt index 12425a41b30..5742846f487 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt @@ -121,7 +121,7 @@ internal fun lightScheme() = CloudStreamColorScheme( onBackground = CloudStreamPalette.LightText, onSurfaceVariant = CloudStreamPalette.LightGrayText, icon = CloudStreamPalette.LightIcon, - primary = CloudStreamPalette.Primary, + primary = CloudStreamPalette.LightPrimary, ongoing = CloudStreamPalette.Ongoing, isLight = true, ) diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt index ab722b330a7..9e586d9935b 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt @@ -66,7 +66,7 @@ fun modeToTheme(mode : CloudStreamThemeMode, primaryColor: CloudStreamPrimaryCol } when { mode == CloudStreamThemeMode.Dynamic -> base - // "Normal" is the theme's own accent, which is NORMAL's colour on all but the default dark + // "Normal" is the theme's own accent, which is NORMAL's colour on all but the dark and light primaryColor == CloudStreamPrimaryColor.NORMAL -> base primaryColor == CloudStreamPrimaryColor.DYNAMIC -> base.copy(primary = dynamicPrimary) primaryColor == CloudStreamPrimaryColor.DYNAMIC_TWO -> base.copy(primary = dynamicSecondary) diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt index dc516736c64..2fe5f322a07 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt @@ -96,6 +96,11 @@ fun AppBar( scrollBehavior: TopAppBarScrollBehavior? = null, ) { + val barColor = backgroundColor ?: if (isActionMode) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + MaterialTheme.colorScheme.background + } Column( modifier = modifier, ) { @@ -121,12 +126,10 @@ fun AppBar( title = titleContent, actions = actions, colors = TopAppBarDefaults.topAppBarColors( - // The page's own colour, so the bar is one with it, as in the rest of the app - containerColor = backgroundColor ?: if (isActionMode) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - MaterialTheme.colorScheme.background - }, + // The page's own colour, so the bar is one with it, as in the rest of the app, also + // when the list scrolls under it + containerColor = barColor, + scrolledContainerColor = barColor, ), scrollBehavior = scrollBehavior, ) diff --git a/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt b/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt index 105c38ab802..495770da4b7 100644 --- a/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt +++ b/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.theme.AppControls import kotlin.math.roundToInt @Composable @@ -23,22 +24,26 @@ fun Slider( valueRange: IntProgression = 0..1, @IntRange(from = 0) steps: Int = with(valueRange) { (last - first) - 1 }, onValueChangeFinished: (() -> Unit)? = null, - colors: SliderColors = SliderDefaults.colors(), + colors: SliderColors = AppControls.sliderColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, thumb: @Composable (SliderState) -> Unit = { SliderDefaults.Thumb( interactionSource = interactionSource, colors = colors, enabled = enabled, - thumbSize = DpSize(4.0.dp, 34.0.dp) + // A round knob on a thin track + thumbSize = DpSize(18.dp, 18.dp) ) }, track: @Composable (SliderState) -> Unit = { sliderState -> SliderDefaults.Track( - modifier = Modifier.height(10.dp), + modifier = Modifier.height(6.dp), colors = colors, enabled = enabled, - sliderState = sliderState + sliderState = sliderState, + drawStopIndicator = null, + drawTick = { _, _ -> }, + thumbTrackGapSize = 0.dp, ) }, ) { diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt index 4f97d9f2b18..9b07078a146 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt @@ -2,6 +2,11 @@ package com.mihon.presentation.settings import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -22,28 +27,35 @@ fun PreferenceScaffold( ) { val (top, bottom) = remember { FocusRequester.createRefs() } - Scaffold( - // TODO @WindowInsets remove this when we have converted everything to compose - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - AppBar( - modifier = Modifier.focusRequester(top).focusProperties { - down = bottom - }, - title = title, - navigateUp = onBackPressed, - actions = actions, - scrollBehavior = it, - ) - }, - content = { contentPadding -> - PreferenceScreen( - modifier = Modifier.focusRequester(bottom).focusProperties { - up = top - }, - items = itemsProvider(), - contentPadding = contentPadding, - ) - }, - ) + // A readable width, centred, rather than rows across a whole wide window + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Scaffold( + modifier = Modifier.widthIn(max = PreferenceMaxWidth), + // TODO @WindowInsets remove this when we have converted everything to compose + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + AppBar( + modifier = Modifier.focusRequester(top).focusProperties { + down = bottom + }, + title = title, + navigateUp = onBackPressed, + actions = actions, + scrollBehavior = it, + ) + }, + content = { contentPadding -> + PreferenceScreen( + modifier = Modifier.focusRequester(bottom).focusProperties { + up = top + }, + items = itemsProvider(), + contentPadding = contentPadding, + ) + }, + ) + } } + +/** How wide a settings page grows */ +val PreferenceMaxWidth = 880.dp diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt index 0e99aa9a3c0..a0f3937f8a9 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt @@ -5,7 +5,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.draw.clip import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -56,31 +62,66 @@ fun PreferenceScreen( PreferenceGroupHeader(title = preference.title) } } - items(preference.preferenceItems) { item -> - PreferenceItem( - item = item, - highlightKey = highlightKey, - ) + val last = preference.preferenceItems.lastIndex + itemsIndexed(preference.preferenceItems) { j, item -> + PreferenceCard(first = j == 0, last = j == last) { + PreferenceItem( + item = item, + highlightKey = highlightKey, + ) + } } item { if (i < items.lastIndex) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } } - // Create Preference Item + // Create Preference Item, in one stack of cards with the items around it outside groups is Preference.PreferenceItem<*, *> -> item { - PreferenceItem( - item = preference, - highlightKey = highlightKey, - ) + if (i == 0) Spacer(modifier = Modifier.height(8.dp)) + PreferenceCard( + first = items.getOrNull(i - 1) !is Preference.PreferenceItem<*, *>, + last = items.getOrNull(i + 1) !is Preference.PreferenceItem<*, *>, + ) { + PreferenceItem( + item = preference, + highlightKey = highlightKey, + ) + } } } } } } +/** + * A group's items as a stack of cards in the theme's box colour, a hair apart, rounded at the top of + * the first and the bottom of the last, so the group reads as one block + */ +@Composable +private fun PreferenceCard(first: Boolean, last: Boolean, content: @Composable () -> Unit) { + val outer = 12.dp + val inner = 4.dp + val shape = RoundedCornerShape( + topStart = if (first) outer else inner, + topEnd = if (first) outer else inner, + bottomStart = if (last) outer else inner, + bottomEnd = if (last) outer else inner, + ) + Box( + Modifier + .padding(horizontal = PreferenceCardGutter) + .padding(bottom = if (last) 0.dp else 2.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { content() } +} + +/** The space at the sides of the cards; the group titles line up with what is inside them */ +internal val PreferenceCardGutter = 16.dp + private fun List.findHighlightedIndex(highlightKey: String): Int { return flatMap { if (it is Preference.PreferenceGroup) { diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt index 3382ebda5c8..70823cad32e 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt @@ -2,6 +2,8 @@ package com.mihon.presentation.settings.widget import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -22,14 +24,16 @@ import com.mihon.material.padding @Composable internal fun InfoWidget(text: String) { - Column( + // The icon beside the text, not on a line of its own + Row( modifier = Modifier .padding( horizontal = PrefsHorizontalPadding, vertical = MaterialTheme.padding.medium, ) .secondaryItemAlpha(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium), + verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(Res.drawable.info), diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt index b7d1197a939..9aa7faf8c94 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.font.FontWeight @Composable fun PreferenceGroupHeader(title: String) { @@ -18,11 +19,12 @@ fun PreferenceGroupHeader(title: String) { .fillMaxWidth() .padding(bottom = 8.dp, top = 14.dp), ) { + // Grey and lined up with the text in the cards below it Text( text = title, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), - style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = PrefsHorizontalPadding * 2), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), ) } } diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt index 2744cdcdc9b..cf0beb00df8 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import com.lagradost.cloudstream4.theme.AppControls import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -31,6 +32,7 @@ fun SwitchPreferenceWidget( Switch( checked = checked, onCheckedChange = null, + colors = AppControls.switchColors(), modifier = Modifier.padding(start = TrailingWidgetBuffer), ) }, From 2aeaeec2709a74e90d4a4f02f2e4a443466591ee Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 17 Oct 2026 00:49:42 +0530 Subject: [PATCH 61/64] Desktop UI: a fuller title page, pill buttons in the theme's accent, a clean hover on light themes - Posters cast their hover shadow from the picture alone, so the title under them no longer sits in a grey box on a light theme; the row arrows are round buttons over a soft fade - Buttons are pills: the main action in the theme's accent, the others in its tonal colour, dialogs too - Title page: the backdrop across most of the window with the poster, logo or name, facts with the score in the accent, genre pills and a story that expands; Play (or Play S1 E1 for a series not started), Start over, the library, download, and round tools for other sources, the trailer, the provider's site and copying the link; how far it was watched; episodes as a grid of cards with progress and a play sign on hover; the cast; More like this; and an About card with everything known about the title Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/browse/DetailScreen.kt | 612 ++++++++++++++---- .../lagradost/cloudstream4/browse/Posters.kt | 54 +- .../drawable/content_copy_24px.xml | 9 + .../drawable/open_in_new_24px.xml | 9 + .../composeResources/drawable/person_24px.xml | 9 + .../lagradost/cloudstream4/compose/Colors.kt | 9 +- .../cloudstream4/theme/Components.kt | 20 +- 7 files changed, 569 insertions(+), 153 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/content_copy_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/open_in_new_24px.xml create mode 100644 shared/src/commonMain/composeResources/drawable/person_24px.xml diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt index ed8338cdeed..1a4cbbd3b33 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/DetailScreen.kt @@ -1,4 +1,41 @@ package com.lagradost.cloudstream4.browse +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.DpOffset +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream4.generated.resources.baseline_theaters_24 +import com.lagradost.cloudstream4.generated.resources.check +import com.lagradost.cloudstream4.generated.resources.content_copy_24px +import com.lagradost.cloudstream4.generated.resources.ic_baseline_add_24 +import com.lagradost.cloudstream4.generated.resources.ic_baseline_replay_24 +import com.lagradost.cloudstream4.generated.resources.open_in_new_24px +import com.lagradost.cloudstream4.generated.resources.person_24px +import java.awt.Desktop +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.net.URI +import kotlinx.coroutines.delay import androidx.compose.foundation.background import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.draw.shadow @@ -31,14 +68,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue 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 @@ -158,45 +194,60 @@ private fun Details( ) } } - LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { - item { - Header( - response = response, - state = state, - onPlay = { fromStart -> play(viewModel.playRequest(fromStart = fromStart), null) }, - onResume = { episode -> play(viewModel.playRequest(episode), episode) }, - onBookmark = { viewModel.onAction(DetailAction.SetBookmark(it)) }, - onSearch = { onSearch(response.name) }, - download = if (state.movieData != null && response.type != TvType.Live) downloadButton(null) else null, - ) - } + BoxWithConstraints(Modifier.fillMaxSize()) { + // Episode cards fill the width, about 440 dp each + val columns = ((maxWidth - PageGutter * 2) / 440.dp).toInt().coerceIn(1, 4) + LazyColumn(contentPadding = PaddingValues(bottom = DockClearance), modifier = Modifier.fillMaxSize()) { + item(key = "hero") { + Hero( + response = response, + state = state, + onPlay = { fromStart -> play(viewModel.playRequest(fromStart = fromStart), null) }, + onResume = { episode -> play(viewModel.playRequest(episode), episode) }, + onBookmark = { viewModel.onAction(DetailAction.SetBookmark(it)) }, + onSearch = { onSearch(response.name) }, + download = if (state.movieData != null && response.type != TvType.Live) downloadButton(null) else null, + ) + } - if (state.dubs.size > 1 || state.seasons.size > 1) item { - Choices(state, viewModel::onAction) - } + if (season != null || state.dubs.size > 1 || state.seasons.size > 1) item(key = "episodes") { + Column(Modifier.padding(horizontal = PageGutter).padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + SectionTitle("Episodes", season?.let { "${it.label} · ${it.episodes.size} episodes" }) + Choices(state, viewModel::onAction) + } + } + if (season != null) { + items(season.episodes.chunked(columns)) { row -> + Row( + Modifier.padding(horizontal = PageGutter, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + row.forEach { episode -> + EpisodeCard(episode, state.progressOf(episode), downloadButton(episode), Modifier.weight(1f)) { + play(viewModel.playRequest(episode), episode) + } + } + // The last row's cards keep the others' width + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } - if (season != null) { - item { - Text( - "${season.label} · ${season.episodes.size} episodes", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = PageGutter, end = PageGutter, top = 16.dp, bottom = 8.dp), + val actors = response.actors.orEmpty() + if (actors.isNotEmpty()) item(key = "cast") { CastRow(actors) } + + val recommendations = response.recommendations.orEmpty() + if (recommendations.isNotEmpty()) item(key = "more") { + PosterRow( + title = "More like this", + items = recommendations, + horizontal = false, + modifier = Modifier.padding(top = 24.dp), + onItemClick = { onOpen(it.apiName, it.url) }, ) } - items(season.episodes) { episode -> - EpisodeRow(episode, state.progressOf(episode), downloadButton(episode)) { play(viewModel.playRequest(episode), episode) } - } - } - val recommendations = response.recommendations.orEmpty() - if (recommendations.isNotEmpty()) item { - PosterRow( - title = "More like this", - items = recommendations, - horizontal = false, - modifier = Modifier.padding(top = 24.dp), - onItemClick = { onOpen(it.apiName, it.url) }, - ) + item(key = "about") { About(response) } } } } @@ -208,99 +259,290 @@ fun formatTime(ms: Long): String { return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) } +/** A section's title, with a line of grey after it */ @Composable -private fun Header( +private fun SectionTitle(title: String, detail: String? = null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RowTitle(title) + detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } +} + +/** + * The top of the page, as on Home: the title's wide picture across most of the window, faded into + * the page, with its poster, name or logo, facts, genres and story, then what can be done with it. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LazyItemScope.Hero( response: LoadResponse, state: DetailState, onPlay: (fromStart: Boolean) -> Unit, - onResume: (com.lagradost.cloudstream3.Episode) -> Unit, + onResume: (Episode) -> Unit, onBookmark: (WatchType) -> Unit, onSearch: () -> Unit, /** The movie's download button, null for a series or a live stream */ download: (@Composable () -> Unit)?, ) { val background = MaterialTheme.colorScheme.background - // The title's wide picture behind it, faded into the page below and on the side of the text, as on Home - Box(Modifier.fillMaxWidth()) { + Box(Modifier.fillParentMaxWidth().heightIn(min = 460.dp).fillParentMaxHeight(0.74f)) { RemoteImage( response.backgroundPosterUrl?.takeIf { it.isNotBlank() } ?: response.posterUrl, response.posterHeaders, contentDescription = null, - modifier = Modifier.matchParentSize(), + modifier = Modifier.fillMaxSize(), corner = 0.dp, ) - Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to background.copy(alpha = 0.35f), 1f to background))) - Box(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to background.copy(alpha = 0.6f), 0.7f to background.copy(alpha = 0.2f)))) + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to background.copy(alpha = 0.25f), 0.5f to Color.Transparent, 1f to background))) + // Strong on the side of the text, so it reads on a bright picture + Box(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to background.copy(alpha = 0.95f), 0.45f to background.copy(alpha = 0.75f), 0.8f to background.copy(alpha = 0.1f), 1f to Color.Transparent))) Row( - Modifier.padding(start = PageGutter, end = PageGutter, top = 72.dp, bottom = 24.dp), + Modifier.align(Alignment.BottomStart).padding(start = PageGutter, end = PageGutter, bottom = 24.dp), horizontalArrangement = Arrangement.spacedBy(28.dp), + verticalAlignment = Alignment.Bottom, ) { RemoteImage( response.posterUrl, response.posterHeaders, contentDescription = response.name, - modifier = Modifier.width(200.dp).height(300.dp).shadow(20.dp, RoundedCornerShape(8.dp)), + modifier = Modifier.width(180.dp).height(270.dp).shadow(24.dp, RoundedCornerShape(8.dp)), ) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(response.name, style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)) - val facts = listOfNotNull( - response.score?.let { "★ ${"%.1f".format(it.toFloat(10))}" }, - response.year?.toString(), - response.type.name, - response.duration?.let { "$it min" }, - response.contentRating, - response.apiName, - ) - Text(facts.joinToString(" · "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - response.tags?.takeIf { it.isNotEmpty() }?.let { - Text(it.joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - response.plot?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) + Column(Modifier.widthIn(max = 760.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + TitleOrLogo(response) + Facts(response, state) + response.tags?.takeIf { it.isNotEmpty() }?.let { tags -> + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + tags.take(6).forEach { Tag(it) } + } } + response.plot?.takeIf { it.isNotBlank() }?.let { Plot(it) } state.unsupported?.let { Text(it, color = MaterialTheme.colorScheme.error) } if (state.unsupported == null && state.movieData == null && state.seasons.isEmpty()) { Text("No episodes yet", color = MaterialTheme.colorScheme.onSurfaceVariant) } - Row( - Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val resume = state.resume - if (state.unsupported == null) when { - // Continue watching left off in an episode - resume?.episode != null -> WhiteButton(onClick = { onResume(resume.episode!!) }) { - Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) - val at = if (resume.positionMs > 0) " at ${formatTime(resume.positionMs)}" else "" - Text("${if (resume.positionMs > 0) "Resume" else "Play"} ${resume.label}$at", modifier = Modifier.padding(start = 8.dp)) - } + Actions(response, state, onPlay, onResume, onBookmark, onSearch, download) + WatchedSoFar(state) + } + } + } +} - state.movieData != null -> { - WhiteButton(onClick = { onPlay(false) }) { - Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) - Text(resume?.let { "Resume at ${formatTime(it.positionMs)}" } ?: "Play", modifier = Modifier.padding(start = 8.dp)) - } - if (resume != null) TextButton(onClick = { onPlay(true) }) { Text("Start over") } - } - } - LibraryButton(state.bookmark, onBookmark) - // The same title from other extensions, when this one's links do not play - BlackButton(onClick = onSearch) { - Icon(painterResource(Res.drawable.search_icon), contentDescription = null) - Text("Search", modifier = Modifier.padding(start = 8.dp)) - } - download?.invoke() +/** The title's logo when the extension gives one, its name otherwise or when the logo does not load */ +@Composable +private fun TitleOrLogo(response: LoadResponse) { + var logoFailed by remember(response.logoUrl) { mutableStateOf(false) } + val logo = response.logoUrl?.takeIf { it.isNotBlank() && !logoFailed } + if (logo != null) AsyncImage( + model = logo, + contentDescription = response.name, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + onError = { logoFailed = true }, + modifier = Modifier.fillMaxWidth(0.7f).height(110.dp), + ) else Text( + response.name, + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) +} + +/** "★ 7.8 · 2024 · Movie · 112 min · PG-13 · Ongoing · Provider", the score in the accent */ +@Composable +private fun Facts(response: LoadResponse, state: DetailState) { + val grey = MaterialTheme.colorScheme.onSurfaceVariant + val facts = listOfNotNull( + response.year?.toString(), + typeName(response.type), + response.duration?.let { "$it min" }, + state.seasons.size.takeIf { it > 1 }?.let { "$it seasons" }, + response.contentRating?.takeIf { it.isNotBlank() }, + (response as? EpisodeResponse)?.showStatus?.name, + response.apiName, + ) + Text( + buildAnnotatedString { + response.score?.let { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)) { + append("★ ${"%.1f".format(it.toFloat(10))}") + } + if (facts.isNotEmpty()) append(" · ") + } + append(facts.joinToString(" · ")) + }, + style = MaterialTheme.typography.bodyMedium, + color = grey, + ) +} + +/** A genre, as a small pill */ +@Composable +private fun Tag(text: String) { + Text( + text, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.10f)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +/** The story, three lines, all of it on a click */ +@Composable +private fun Plot(plot: String) { + var expanded by remember(plot) { mutableStateOf(false) } + var overflows by remember(plot) { mutableStateOf(false) } + Column { + Text( + plot, + style = MaterialTheme.typography.bodyLarge, + maxLines = if (expanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + onTextLayout = { if (!expanded) overflows = it.hasVisualOverflow }, + ) + if (overflows || expanded) Text( + if (expanded) "Less" else "More", + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp).clip(RoundedCornerShape(4.dp)).clickable { expanded = !expanded }.padding(2.dp), + ) + } +} + +/** + * Play or resume, large in the accent; the library and download; then small round tools: other + * sources, the trailer, the title on the provider's site and copying its link + */ +@Composable +private fun Actions( + response: LoadResponse, + state: DetailState, + onPlay: (fromStart: Boolean) -> Unit, + onResume: (Episode) -> Unit, + onBookmark: (WatchType) -> Unit, + onSearch: () -> Unit, + download: (@Composable () -> Unit)?, +) { + val big = PaddingValues(horizontal = 28.dp, vertical = 12.dp) + Row( + Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val resume = state.resume + if (state.unsupported == null) when { + // Continue watching left off in an episode + resume?.episode != null -> WhiteButton(onClick = { onResume(resume.episode!!) }, contentPadding = big) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + val verb = if (resume.positionMs > 0) "Resume" else "Play" + Text("$verb ${resume.label}", modifier = Modifier.padding(start = 8.dp)) + } + + state.movieData != null -> { + WhiteButton(onClick = { onPlay(false) }, contentPadding = big) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + Text(if (resume != null) "Resume" else "Play", modifier = Modifier.padding(start = 8.dp)) + } + if (resume != null) BlackButton(onClick = { onPlay(true) }) { + Icon(painterResource(Res.drawable.ic_baseline_replay_24), contentDescription = null, modifier = Modifier.size(20.dp)) + Text("Start over", modifier = Modifier.padding(start = 8.dp)) + } + } + + // A series not started yet begins at its first episode + else -> state.seasons.firstOrNull()?.episodes?.firstOrNull()?.let { first -> + WhiteButton(onClick = { onResume(first) }, contentPadding = big) { + Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) + val label = listOfNotNull(first.season?.let { "S$it" }, first.episode?.let { "E$it" }).joinToString(" ") + Text(if (label.isEmpty()) "Play" else "Play $label", modifier = Modifier.padding(start = 8.dp)) } } } + LibraryButton(state.bookmark, onBookmark) + download?.let { RoundTool("Download") { it() } } + RoundTool("Find in other sources", onClick = onSearch) { + Icon(painterResource(Res.drawable.search_icon), contentDescription = null, modifier = Modifier.size(20.dp)) + } + response.trailers.firstOrNull()?.let { trailer -> + RoundTool("Watch the trailer", onClick = { openInBrowser(trailer.extractorUrl) }) { + Icon(painterResource(Res.drawable.baseline_theaters_24), contentDescription = null, modifier = Modifier.size(20.dp)) + } + } + RoundTool("Open on ${response.apiName}'s site", onClick = { openInBrowser(response.url) }) { + Icon(painterResource(Res.drawable.open_in_new_24px), contentDescription = null, modifier = Modifier.size(20.dp)) + } + var copied by remember { mutableStateOf(false) } + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + RoundTool(if (copied) "Link copied" else "Copy the link", onClick = { + copyText(response.url) + copied = true + }) { + Icon( + painterResource(if (copied) Res.drawable.check else Res.drawable.content_copy_24px), + contentDescription = null, + tint = if (copied) MaterialTheme.colorScheme.primary else LocalContentColor.current, + modifier = Modifier.size(20.dp), + ) + } + } +} + +/** How far the movie, or the episode to resume, was watched: a thin bar and the time left */ +@Composable +private fun WatchedSoFar(state: DetailState) { + val resume = state.resume ?: return + val progress = resume.episode?.let(state::progressOf) ?: state.titleId?.let(state.progress::get) ?: return + if (progress.watched || progress.positionMs <= 0 || progress.durationMs <= 0) return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LinearProgressIndicator( + progress = { progress.fraction }, + modifier = Modifier.width(220.dp).height(4.dp), + trackColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.15f), + strokeCap = StrokeCap.Round, + gapSize = 0.dp, + drawStopIndicator = {}, + ) + val left = (progress.durationMs - progress.positionMs) / 60_000 + Text( + if (left > 0) "$left min left" else "Almost done", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** A round button of the theme's tonal colour, its name on hover */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RoundTool(name: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) { + TooltipArea( + tooltip = { GlassTooltip(name) }, + delayMillis = 300, + tooltipPlacement = TooltipPlacement.ComponentRect(anchor = Alignment.BottomCenter, alignment = Alignment.BottomCenter, offset = DpOffset(0.dp, 8.dp)), + ) { + Box( + Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center, + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { content() } + } } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { - Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { if (state.dubs.size > 1) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { state.dubs.forEach { dub -> AppFilterChip( @@ -326,77 +568,199 @@ private fun Choices(state: DetailState, onAction: (DetailAction) -> Unit) { @Composable private fun LibraryButton(current: WatchType, onSelect: (WatchType) -> Unit) { var open by remember { mutableStateOf(false) } + val colors = MaterialTheme.colorScheme Box { BlackButton(onClick = { open = true }) { - Text(if (current == WatchType.NONE) "Add to library" else current.label) + Icon( + painterResource(if (current == WatchType.NONE) Res.drawable.ic_baseline_add_24 else Res.drawable.check), + contentDescription = null, + tint = if (current == WatchType.NONE) LocalContentColor.current else colors.primary, + modifier = Modifier.size(20.dp), + ) + Text(if (current == WatchType.NONE) "My library" else current.label, modifier = Modifier.padding(start = 8.dp)) } - DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + offset = DpOffset(0.dp, 8.dp), + shape = RoundedCornerShape(16.dp), + containerColor = colors.surfaceContainer, + tonalElevation = 0.dp, + shadowElevation = 16.dp, + border = BorderStroke(1.dp, colors.onBackground.copy(alpha = 0.08f)), + ) { WatchType.lists.forEach { type -> + val selected = type == current DropdownMenuItem( - text = { Text(if (type == current) "${type.label} ✓" else type.label) }, + text = { Text(type.label, color = if (selected) colors.primary else colors.onBackground, fontWeight = if (selected) FontWeight.SemiBold else null) }, + trailingIcon = if (selected) { + { Icon(painterResource(Res.drawable.check), contentDescription = null, tint = colors.primary, modifier = Modifier.size(18.dp)) } + } else null, onClick = { open = false onSelect(type) }, + modifier = Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)), ) } if (current != WatchType.NONE) DropdownMenuItem( - text = { Text("Remove from library") }, + text = { Text("Remove from library", color = colors.error) }, onClick = { open = false onSelect(WatchType.NONE) }, + modifier = Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)), ) } } } +/** + * An episode as a card in the grid: its picture with how far it was watched along the bottom and a + * play sign on hover, its name, story and length, and its download button + */ @Composable -private fun EpisodeRow(episode: Episode, progress: PlaybackPosition?, download: @Composable () -> Unit, onClick: () -> Unit) { - // A rounded line that lights under the mouse, the pages' gutter on both sides +private fun EpisodeCard( + episode: Episode, + progress: PlaybackPosition?, + download: @Composable () -> Unit, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val colors = MaterialTheme.colorScheme Row( - Modifier - .fillMaxWidth() - .padding(horizontal = PageGutter - 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(10.dp)) + modifier + .height(104.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (hovered) colors.surfaceVariant else colors.surfaceContainer) + .hoverable(interaction) .clickable(onClick = onClick) .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (episode.posterUrl != null) { - RemoteImage(episode.posterUrl, null, contentDescription = null, modifier = Modifier.width(160.dp).height(90.dp), corner = 6.dp) - } else { - Box( - Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceContainer), + Box(Modifier.width(156.dp).fillMaxHeight().clip(RoundedCornerShape(8.dp)).background(colors.surface)) { + if (episode.posterUrl != null) RemoteImage(episode.posterUrl, null, contentDescription = null, modifier = Modifier.fillMaxSize(), corner = 0.dp) + if (hovered || episode.posterUrl == null) Box( + Modifier.align(Alignment.Center).size(36.dp).background(Color.Black.copy(alpha = 0.55f), CircleShape), contentAlignment = Alignment.Center, - ) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null) } + ) { Icon(painterResource(Res.drawable.play_arrow_24px), contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp)) } + if (progress != null && progress.positionMs > 0) { + val fraction = if (progress.watched) 1f else progress.fraction + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(3.dp).background(Color.Black.copy(alpha = 0.4f))) + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth(fraction).height(3.dp).background(colors.primary)) + } } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(DetailViewModel.episodeLabel(episode), style = MaterialTheme.typography.titleSmall) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + DetailViewModel.episodeLabel(episode), + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) episode.description?.takeIf { it.isNotBlank() }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(it, style = MaterialTheme.typography.bodySmall, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) } - if (progress != null && !progress.watched && progress.positionMs > 0) { - LinearProgressIndicator( - progress = { progress.fraction }, - modifier = Modifier.fillMaxWidth(0.5f).padding(top = 4.dp).height(3.dp), - trackColor = MaterialTheme.colorScheme.surface, - strokeCap = StrokeCap.Round, - gapSize = 0.dp, - drawStopIndicator = {}, - ) + val meta = when { + progress?.watched == true -> "Watched ✓" + progress != null && progress.positionMs > 0 -> "${formatTime(progress.positionMs)} / ${formatTime(progress.durationMs)}" + else -> episode.runTime?.let { "$it min" } + } + meta?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = if (progress?.watched == true) colors.primary else colors.onSurfaceVariant) } - } - when { - progress?.watched == true -> Text("Watched ✓", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) - progress != null && progress.positionMs > 0 -> Text("${formatTime(progress.positionMs)} / ${formatTime(progress.durationMs)}", style = MaterialTheme.typography.bodySmall) - else -> episode.runTime?.let { Text("$it min", style = MaterialTheme.typography.bodySmall) } } download() } } +/** The people in it, as round pictures with their names and parts */ +@Composable +private fun CastRow(actors: List) { + Column(Modifier.padding(top = 24.dp)) { + RowTitle("Cast", Modifier.padding(horizontal = PageGutter, vertical = 4.dp)) + ScrollingRow { + items(actors) { data -> + Column(Modifier.width(96.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(80.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surface), contentAlignment = Alignment.Center) { + Icon(painterResource(Res.drawable.person_24px), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(36.dp)) + data.actor.image?.takeIf { it.isNotBlank() }?.let { + RemoteImage(it, null, contentDescription = data.actor.name, modifier = Modifier.fillMaxSize(), corner = 40.dp) + } + } + Text(data.actor.name, style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + data.roleString?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + } + } + } + } + } +} + +/** Everything known about the title, in a card at the bottom of the page */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun About(response: LoadResponse) { + val facts = listOfNotNull( + "Provider" to response.apiName, + "Type" to typeName(response.type), + response.year?.let { "Year" to it.toString() }, + response.duration?.let { "Runtime" to "$it min" }, + response.contentRating?.takeIf { it.isNotBlank() }?.let { "Age rating" to it }, + (response as? EpisodeResponse)?.showStatus?.let { "Status" to it.name }, + response.score?.let { "Score" to "%.1f / 10".format(it.toFloat(10)) }, + response.tags?.takeIf { it.isNotEmpty() }?.let { "Genres" to it.joinToString(", ") }, + ) + Column(Modifier.padding(start = PageGutter, end = PageGutter, top = 28.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + RowTitle("About") + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surfaceContainer).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + facts.forEach { (label, value) -> + Column(Modifier.widthIn(min = 120.dp, max = 360.dp)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium) + } + } + } + Column { + Text("Link", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + response.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clip(RoundedCornerShape(4.dp)).clickable { openInBrowser(response.url) }, + ) + } + } + } +} + +/** "Movie", "Series", "Anime"... */ +private fun typeName(type: TvType): String = when (type) { + TvType.TvSeries -> "Series" + TvType.AsianDrama -> "Asian drama" + TvType.AnimeMovie -> "Anime movie" + TvType.OVA -> "OVA" + else -> type.name.replace(Regex("(?<=[a-z])(?=[A-Z])"), " ").lowercase().replaceFirstChar { it.uppercase() } +} + +private fun openInBrowser(url: String) { + runCatching { Desktop.getDesktop().browse(URI(url)) } + .onFailure { println("WARN DetailScreen: could not open $url: $it") } +} + +private fun copyText(text: String) { + runCatching { Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null) } +} + /** The id a download is saved under: the episode's, or the title's for a movie */ private fun downloadId(state: DetailState, episode: Episode?): Int? = if (episode == null) state.titleId else state.episodeIds[episode] diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt index c71388e0598..c97fd25d45c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/browse/Posters.kt @@ -2,6 +2,10 @@ package com.lagradost.cloudstream4.browse import androidx.compose.foundation.background import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.hoverable @@ -126,17 +130,23 @@ val PageGutter = 32.dp /** Room left under a page's last row, so the floating dock at the bottom does not cover it */ val DockClearance = 104.dp -/** Grows a little under the mouse, as cards do in streaming apps, over its neighbours */ +/** + * Grows a little under the mouse, as cards do in streaming apps, over its neighbours. With [shadow] + * the whole card casts one; a poster with its title under it casts it from the picture alone, see + * [PosterCard], or the text would sit in a grey box on a light theme. + */ @Composable -fun Modifier.hoverZoom(interaction: MutableInteractionSource, enabled: Boolean = true): Modifier { +fun Modifier.hoverZoom(interaction: MutableInteractionSource, enabled: Boolean = true, shadow: Boolean = true): Modifier { val hovered by interaction.collectIsHoveredAsState() val scale by animateFloatAsState(if (hovered && enabled) 1.06f else 1f, tween(150)) return zIndex(if (hovered) 1f else 0f) .graphicsLayer { scaleX = scale scaleY = scale - shadowElevation = (scale - 1f) * 200f - shape = RoundedCornerShape(8.dp) + if (shadow) { + shadowElevation = (scale - 1f) * 200f + shape = RoundedCornerShape(8.dp) + } } .hoverable(interaction) } @@ -145,17 +155,19 @@ fun Modifier.hoverZoom(interaction: MutableInteractionSource, enabled: Boolean = @Composable fun PosterCard(item: SearchResponse, horizontal: Boolean, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) { val interaction = remember { MutableInteractionSource() } + val hovered by interaction.collectIsHoveredAsState() + val lift by animateDpAsState(if (hovered && onClick != null) 14.dp else 0.dp, tween(150)) Column( modifier .width(if (horizontal) horizontalWidth else verticalWidth) - .hoverZoom(interaction, enabled = onClick != null) - .then(if (onClick != null) Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick) else Modifier) + .hoverZoom(interaction, enabled = onClick != null, shadow = false) + .then(if (onClick != null) Modifier.clickable(interactionSource = null, indication = null, onClick = onClick) else Modifier) ) { RemoteImage( item.posterUrl, item.posterHeaders, contentDescription = item.name, - modifier = Modifier.fillMaxWidth().aspectRatio(if (horizontal) 16f / 9f else 2f / 3f), + modifier = Modifier.fillMaxWidth().aspectRatio(if (horizontal) 16f / 9f else 2f / 3f).shadow(lift, RoundedCornerShape(8.dp)), ) Text( item.name, @@ -226,28 +238,42 @@ fun ScrollingRow(content: LazyListScope.() -> Unit) { content = content, ) Row(Modifier.matchParentSize()) { - if (hovered && state.canScrollBackward) RowArrow(Res.drawable.chevron_left, "Scroll back") { + if (hovered && state.canScrollBackward) RowArrow(Res.drawable.chevron_left, "Scroll back", start = true) { scope.launch { state.animateScrollBy(-width * 0.8f) } } Spacer(Modifier.weight(1f)) - if (hovered && state.canScrollForward) RowArrow(Res.drawable.chevron_right, "Scroll on") { + if (hovered && state.canScrollForward) RowArrow(Res.drawable.chevron_right, "Scroll on", start = false) { scope.launch { state.animateScrollBy(width * 0.8f) } } } } } +/** + * A round button in the theme's colours at an end of a row, over the posters faded into the page, + * so it shows on any picture and on light themes alike + */ @Composable -private fun RowArrow(icon: DrawableResource, description: String, onClick: () -> Unit) { +private fun RowArrow(icon: DrawableResource, description: String, start: Boolean, onClick: () -> Unit) { + val background = MaterialTheme.colorScheme.background + val fade = listOf(background.copy(alpha = 0.85f), Color.Transparent) Box( Modifier - .width(48.dp) + .width(72.dp) .fillMaxHeight() - .background(Color.Black.copy(alpha = 0.55f)) - .clickable(onClick = onClick), + .background(Brush.horizontalGradient(if (start) fade else fade.reversed())), contentAlignment = Alignment.Center, ) { - Icon(painterResource(icon), contentDescription = description, tint = Color.White, modifier = Modifier.size(36.dp)) + Box( + Modifier + .size(44.dp) + .shadow(10.dp, CircleShape) + .background(MaterialTheme.colorScheme.surface, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(painterResource(icon), contentDescription = description, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(28.dp)) + } } } diff --git a/shared/src/commonMain/composeResources/drawable/content_copy_24px.xml b/shared/src/commonMain/composeResources/drawable/content_copy_24px.xml new file mode 100644 index 00000000000..23673c76251 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/content_copy_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/open_in_new_24px.xml b/shared/src/commonMain/composeResources/drawable/open_in_new_24px.xml new file mode 100644 index 00000000000..4243969a791 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/open_in_new_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/person_24px.xml b/shared/src/commonMain/composeResources/drawable/person_24px.xml new file mode 100644 index 00000000000..2a5d2bebfd2 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/person_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt index 6a363909f59..2bd11a32ba4 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt @@ -14,11 +14,12 @@ object Colors { disabledContentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f) ) + // The main action in the theme's accent, as theme.WhiteButton internal val whiteButton @Composable @ReadOnlyComposable get() = ButtonColors( - containerColor = MaterialTheme.colorScheme.onBackground, - contentColor = MaterialTheme.colorScheme.surfaceVariant, - disabledContainerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), - disabledContentColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f) + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), + disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f) ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt index 16ca679a1f7..9a650347b14 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Components.kt @@ -22,18 +22,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** - * The Android app's buttons and chips, from styles.xml. Material's own buttons are pills in the - * primary colour; the app's are square-cornered, white for the main action and dark for the others. + * The app's buttons and chips: pills, as the dock and the bars are, the main action in the theme's + * accent and the others in its tonal surface colour. */ object AppShapes { - /** rounded_button_radius in dimens.xml */ - val button = RoundedCornerShape(4.dp) + val button = RoundedCornerShape(percent = 50) /** dialog__window_background.xml */ val dialog = RoundedCornerShape(10.dp) } -/** WhiteButton in styles.xml: the main action, such as Play */ +/** The main action, such as Play: the theme's accent. Named after WhiteButton in the Android app's styles.xml */ @Composable fun WhiteButton( onClick: () -> Unit, @@ -43,13 +42,12 @@ fun WhiteButton( content: @Composable RowScope.() -> Unit, ) = NiceButton( onClick, modifier, enabled, contentPadding, - container = MaterialTheme.colorScheme.onBackground, - // iconGrayBackground - contentColor = MaterialTheme.colorScheme.surface, + container = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, content = content, ) -/** BlackButton in styles.xml: every other action, such as Download */ +/** Every other action, such as Download: the theme's tonal surface. After BlackButton in styles.xml */ @Composable fun BlackButton( onClick: () -> Unit, @@ -65,7 +63,7 @@ fun BlackButton( content = content, ) -/** NiceButton in styles.xml: 40 dp high, no shadow, bold 15 sp text */ +/** After NiceButton in styles.xml: 44 dp high, no shadow, bold 15 sp text */ @Composable private fun NiceButton( onClick: () -> Unit, @@ -77,7 +75,7 @@ private fun NiceButton( content: @Composable RowScope.() -> Unit, ) = Button( onClick = onClick, - modifier = modifier.defaultMinSize(minHeight = 40.dp), + modifier = modifier.defaultMinSize(minHeight = 44.dp), enabled = enabled, shape = AppShapes.button, colors = ButtonDefaults.buttonColors( From 074ba3afc8796ba29e41e24bd1e48984fc436ccf Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 17 Oct 2026 01:01:26 +0530 Subject: [PATCH 62/64] Downloads: deleting a download cleans up safely, even a file held open A file Windows would not delete for a moment (the player or a virus scan holding it) was left on the disk for good, its record already gone. It is tried again for a few seconds, then deleted when the app closes. Folders a deleted download leaves empty, such as a series' folder after its last episode, go too; the downloads folder stays. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cloudstream4/download/Downloads.kt | 38 ++++++++++++++++++- .../cloudstream4/download/DownloadsTest.kt | 34 +++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt index bdda6d6b3c9..8f680f62dc9 100644 --- a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/download/Downloads.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -187,14 +188,32 @@ class Downloads( /** Stops the download and deletes its file and subtitles */ fun delete(parentId: Int, id: Int) { val job = jobs.remove(id) - val files = listOfNotNull(store.file(id)?.file) + subtitlesOf(id) + val saved = store.file(id) + val files = listOfNotNull(saved?.file) + subtitlesOf(id) set(id, null) store.remove(parentId, id) scope.launch { // The file is open until the download stops, and Windows cannot delete an open file job?.cancelAndJoin() - withContext(Dispatchers.IO) { files.forEach { it.delete() } } + withContext(Dispatchers.IO) { + files.forEach { deleteFile(it) } + saved?.let { removeEmptyFolders(it.folder, it.root) } + } + } + } + + /** + * Deletes a file, trying again for a few seconds while something holds it open, as the player or + * a virus scan can on Windows. Its record is gone already, so a file left now would stay on the + * disk for good: one that still cannot go is deleted when the app closes. + */ + private suspend fun deleteFile(file: File) { + repeat(DELETE_TRIES) { + if (!file.exists() || file.delete()) return + delay(DELETE_RETRY_MS) } + println("WARN Downloads: could not delete $file, it goes when the app closes") + file.deleteOnExit() } private fun subtitlesOf(id: Int): List = store.file(id)?.file?.let(::subtitleFiles).orEmpty() @@ -329,6 +348,21 @@ class Downloads( companion object { private val SUBTITLE_EXTENSIONS = setOf("vtt", "srt", "txt", "ass", "ttml", "sbv", "dfxp") private const val SAVE_EVERY_NS = 2_000_000_000L + private const val DELETE_TRIES = 10 + private const val DELETE_RETRY_MS = 500L + + /** + * Removes [folder] and the folders above it that a deleted download left empty, such as a + * series' folder after its last episode, up to [root], the downloads folder, which stays + */ + internal fun removeEmptyFolders(folder: File, root: File) { + val stop = root.canonicalFile + var current: File? = folder.canonicalFile + while (current != null && current != stop && current.startsWith(stop) && current.list()?.isEmpty() == true) { + if (!current.delete()) return + current = current.parentFile + } + } /** Links that can be saved to a file: not DRM, not torrents or web pages */ fun downloadable(link: ExtractorLink) = link !is DrmExtractorLink && Downloader.canDownload(link) diff --git a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt index ef158b26a98..80e2b890216 100644 --- a/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt +++ b/shared/src/jvmTest/kotlin/com/lagradost/cloudstream4/download/DownloadsTest.kt @@ -157,6 +157,38 @@ class DownloadsTest { withTimeout(5_000) { while (file.exists() || subtitle.exists()) kotlinx.coroutines.delay(50) } assertNull(again.state(70)) assertTrue(again.titles().isEmpty()) + // The series' folders it left empty go too, the downloads folder stays + withTimeout(5_000) { while (File(root, "TVSeries").exists()) kotlinx.coroutines.delay(50) } + assertTrue(root.isDirectory) + scope.cancel() + } + + @Test + fun aHalfDownloadHeldOpenIsStillDeletedOnceItIsLetGo() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val root = File(dir, "Downloads") + val store = DownloadStore() + val title = DownloadedTitle(11, "Test", "$base/film3", "Held film", TvType.Movie, null) + val movie = DownloadedEpisode(11, 11, null, null, null, null, null) + store.saveTitle(title) + store.saveEpisode(movie, DownloadSource("Test", "data")) + val partial = DownloadFile(video.size.toLong(), root, "Movies", "Held film.mp4") + partial.folder.mkdirs() + partial.file.writeBytes(video.copyOf(100_000)) + File(root, "Movies/Other film.mp4").writeBytes(ByteArray(10)) + store.saveFile(11, partial) + val downloads = Downloads(store, scope, LinkFinder { _, _, _ -> }, root = { root }) + assertEquals(DownloadStatus.Paused, downloads.state(11)?.status) + + // Something else has it open, as the player or a virus scan can: Windows refuses to delete it + val held = java.io.FileInputStream(partial.file) + downloads.delete(11, 11) + assertNull(downloads.state(11)) + kotlinx.coroutines.delay(800) + held.close() + withTimeout(8_000) { while (partial.file.exists()) kotlinx.coroutines.delay(50) } + // Another film is still in the folder, so it stays + assertTrue(File(root, "Movies").isDirectory) scope.cancel() } @@ -222,6 +254,8 @@ class DownloadsTest { withTimeout(5_000) { while (partial.file.exists()) kotlinx.coroutines.delay(50) } store.saveTitle(title) store.saveEpisode(movie, request.source) + // Deleting took the folder it left empty with it + partial.folder.mkdirs() partial.file.writeBytes(video.copyOf(100_000)) store.saveFile(9, partial) mirror = false From d9b360158beb4f61170fb026bdf4622c2dbbad09 Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 17 Oct 2026 01:48:27 +0530 Subject: [PATCH 63/64] CI: spend far fewer Actions minutes - Desktop Build checks pull requests only, not the merge again; a new push cancels the check of the commit before it; the installer is built only when run by hand; time limit 40 min - Release runs only when asked (Run workflow on master, or a v tag), no longer on every merge, and links to the libmpv release for the dll's source instead of uploading the same 462 MB with every release - libmpv (about 70 min a build) runs only by hand, not on pull requests that touch it Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/desktop.yml | 21 +++++-- .github/workflows/libmpv.yml | 3 - .github/workflows/release.yml | 55 +++++++++++-------- .../common/THIRD-PARTY-NOTICES.txt | 3 +- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 654deeb7bb4..438a3c761d0 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -1,19 +1,29 @@ name: Desktop Build +# Checks a pull request: the tests, and that the desktop app compiles. Nothing runs on the merge +# itself, the pull request was checked already. The installer is only built when the workflow is +# run by hand ("Run workflow" in the Actions tab), to try a build before releasing it. + on: pull_request: - push: - branches: [ master ] paths-ignore: - - '*.md' + - '**.md' + - '.github/workflows/libmpv.yml' + - '.github/workflows/release.yml' workflow_dispatch: permissions: contents: read +# A new push to a pull request stops the check of the commit before it +concurrency: + group: desktop-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: windows: runs-on: windows-latest + timeout-minutes: 40 defaults: run: shell: bash @@ -33,12 +43,14 @@ jobs: cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Run tests - run: ./gradlew :library:jvmTest :shared:jvmTest + run: ./gradlew :library:jvmTest :shared:jvmTest :desktopApp:jvmTest - name: Build MSI + if: github.event_name == 'workflow_dispatch' run: ./gradlew :desktopApp:packageMsi - name: Upload MSI + if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v7 with: name: desktop-windows-msi @@ -53,3 +65,4 @@ jobs: path: | library/build/reports/tests/jvmTest/ shared/build/reports/tests/jvmTest/ + desktopApp/build/reports/tests/jvmTest/ diff --git a/.github/workflows/libmpv.yml b/.github/workflows/libmpv.yml index 06c2c041153..855bcbaa0d4 100644 --- a/.github/workflows/libmpv.yml +++ b/.github/workflows/libmpv.yml @@ -23,9 +23,6 @@ on: description: Publish the build and its source as a release of this repository type: boolean default: false - pull_request: - paths: - - .github/workflows/libmpv.yml permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index defeb9614e6..5ec8b5c7515 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,30 +1,28 @@ name: Release -# Publishes the Windows app. +# Publishes the Windows app, only when asked: merges into master publish nothing, so they cost no +# build of their own. # -# - Every merge into master publishes a pre-release, v-beta., where is -# versionName in gradle/libs.versions.toml and follows the last beta of that version. -# Installed apps find it through the update check in Settings. -# - Pushing a tag v by hand publishes that version as a full release. A tag with a suffix, -# such as v4.8.0-rc.1, makes a pre-release. After a full release, raise versionName before the next -# merge: its betas would otherwise come before the release itself. +# - "Run workflow" in the Actions tab, on master, publishes a pre-release, v-beta., +# where is versionName in gradle/libs.versions.toml and follows the last beta of +# that version. Installed apps find it through the update check in Settings. +# - Pushing a tag v publishes that version as a full release. A tag with a suffix, such as +# v4.8.0-rc.1, makes a pre-release. After a full release, raise versionName before the next beta: +# it would otherwise come before the release itself. # -# The release holds the installer and, as THIRD-PARTY-NOTICES.txt promises, the complete source of the -# libmpv-2.dll inside it, copied from the libmpv release the build downloads it from. +# The release holds the installer, and its notes link to the libmpv release the build downloads +# libmpv-2.dll from, which holds the dll's complete source, as THIRD-PARTY-NOTICES.txt says. The +# source is not copied into every release: it is 462 MB, the same each time. on: + workflow_dispatch: push: - branches: [ master ] tags: [ 'v*' ] - # Path filters only apply to the branch: a tag always releases - paths-ignore: - - '*.md' - - '.github/workflows/libmpv.yml' permissions: contents: write -# One release at a time, so two merges close together never pick the same beta number +# One release at a time, so two runs close together never pick the same beta number concurrency: group: release cancel-in-progress: false @@ -32,12 +30,19 @@ concurrency: jobs: windows: runs-on: windows-latest + timeout-minutes: 45 defaults: run: shell: bash steps: - uses: actions/checkout@v6 + - name: Only master or a tag + if: github.ref_type != 'tag' && github.ref != 'refs/heads/master' + run: | + echo "::error::Run the release on master, or push a v tag. $GITHUB_REF would publish a branch that was not merged" + exit 1 + - name: Choose the version env: GH_TOKEN: ${{ github.token }} @@ -63,20 +68,21 @@ jobs: echo "TAG=$tag" >> "$GITHUB_ENV" echo "PREVIOUS=$previous" >> "$GITHUB_ENV" - # Before the build, so a release never goes out without the source it promises - - name: Get the source of the bundled libmpv + # Before the build, so a release never goes out without the source it links to. Only the list of + # files is read, the source itself stays where it is + - name: Find the source of the bundled libmpv env: GH_TOKEN: ${{ github.token }} run: | libmpv=$(grep -o 'releases/download/[^/"]*' desktopApp/build.gradle.kts | head -1 | cut -d/ -f3) echo "libmpv comes from release $libmpv" - mkdir libmpv-source - gh release download "$libmpv" --repo "$GITHUB_REPOSITORY" --dir libmpv-source --pattern '*-source.7z.*' --pattern '*-sources.txt' - if ! ls libmpv-source/*-source.7z.001 > /dev/null 2>&1; then + assets=$(gh release view "$libmpv" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name') + echo "$assets" + if ! grep -q -- '-source.7z.001$' <<< "$assets"; then echo "::error::Release $libmpv has no libmpv source. Build libmpv with the libmpv workflow (publish) and point desktopApp/build.gradle.kts at it" exit 1 fi - ls -la libmpv-source + echo "LIBMPV=$libmpv" >> "$GITHUB_ENV" - name: Set up JDK 17 uses: actions/setup-java@v5 @@ -88,7 +94,8 @@ jobs: uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache-read-only: true + # Releases run on master or a tag: their cache serves the pull requests' checks + cache-read-only: false - name: Run tests run: ./gradlew :library:jvmTest :shared:jvmTest @@ -116,8 +123,8 @@ jobs: echo echo "SHA-256 of the installer: \`$(sha256sum "$msi" | cut -d' ' -f1)\`" echo - echo "The \`*-source.7z.*\` files are the complete source of the libmpv-2.dll the app plays video with (GPL, see THIRD-PARTY-NOTICES.txt), with the list of every library's version in \`*-sources.txt\`." + echo "The complete source of the libmpv-2.dll the app plays video with (GPL, see THIRD-PARTY-NOTICES.txt) is in the [$LIBMPV](https://github.com/$GITHUB_REPOSITORY/releases/tag/$LIBMPV) release: the \`*-source.7z.*\` files, with the list of every library's version in \`*-sources.txt\`." } > notes.md - gh release create "$TAG" "$msi" libmpv-source/* --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + gh release create "$TAG" "$msi" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ --title "CloudStream ${TAG#v}" --notes-file notes.md --generate-notes \ ${PREVIOUS:+--notes-start-tag "$PREVIOUS"} $prerelease diff --git a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt index b71c780323f..2c38da90ba8 100644 --- a/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt +++ b/desktopApp/src/app-resources/common/THIRD-PARTY-NOTICES.txt @@ -21,8 +21,7 @@ https://github.com/harsh-pockt/cloudstream/releases/tag/libmpv-20261004-git-413f The complete corresponding source code of libmpv-2.dll, with the build scripts and the exact version of every library in mpv-dev-x86_64-20261004-git-413ff0b1cd-sources.txt, is published in that -release and with each CloudStream desktop release on GitHub, next to the -installer. +release, which each CloudStream desktop release on GitHub links to. Source code: mpv https://github.com/mpv-player/mpv From 15edd18dbacf89cb907484df6a06b8e5d063e3ad Mon Sep 17 00:00:00 2001 From: harsh-pockt Date: Sat, 17 Oct 2026 14:44:37 +0530 Subject: [PATCH 64/64] Desktop player: a far seek that the server never answers opens the file again there A seek past what is loaded asks the server for that part of the file. Some file hosts never answer that request, and mpv then waits at the new spot for good, the spinner turning: mpv's network-timeout does not end a stalled transfer. After 12 s of waiting the file is opened again at that point, on a new connection, with the chosen audio track and subtitle, at most twice per seek. A slow host that does answer (8 s in tests) is left alone. mpv's errors now go to the app's log, so a refusing server shows there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../com/lagradost/cloudstream4/player/Mpv.kt | 75 +++++++++++++++++-- .../cloudstream4/player/PlayerScreen.kt | 1 + 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt index d187b32a146..8561f3ee95f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/Mpv.kt @@ -35,6 +35,7 @@ internal interface MpvLibrary : Library { fun mpv_free(data: Pointer) fun mpv_error_string(error: Int): String fun mpv_wait_event(ctx: Pointer, timeout: Double): Pointer + fun mpv_request_log_messages(ctx: Pointer, minLevel: String): Int fun mpv_terminate_destroy(ctx: Pointer) } @@ -216,6 +217,13 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { private val afterLoad = mutableListOf<() -> Unit>() /** Where the file opening should start, in seconds, until it has started there */ @Volatile private var resumeAt: Double? = null + /** The link's own audio tracks and the subtitle files added to the file, to add again when it is opened again */ + private var audioUrls = emptyList() + private val subtitlesAdded = mutableListOf>() + /** Since when, in ms, mpv has been waiting to play from a seek, 0 when it is not */ + private var stalledSince = 0L + /** How often the file was opened again for the seek waited on */ + private var reopens = 0 private val _events = Channel(Channel.UNLIMITED) val events: Flow = _events.receiveAsFlow() @@ -308,6 +316,8 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { // Links come from the extensions already: no youtube-dl lookup before every file option("ytdl", "no") check("initialize", lib.mpv_initialize(ctx)) + // mpv's errors, such as a server refusing a request, go to the app's log + check("request_log_messages", lib.mpv_request_log_messages(ctx, "error")) eventThread = Thread(::eventLoop, "mpv-events").apply { isDaemon = true; start() } pending.forEach { it() } pending.clear() @@ -342,6 +352,13 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { val event = lib.mpv_wait_event(ctx, -1.0) when (event.getInt(0)) { MPV_EVENT_SHUTDOWN -> return + MPV_EVENT_LOG_MESSAGE -> { + // mpv_event_log_message: prefix, level, text + val data = event.getPointer(16) + val prefix = data.getPointer(0).getString(0, "UTF-8") + val text = data.getPointer(2L * Native.POINTER_SIZE).getString(0, "UTF-8") + println("ERROR mpv [$prefix]: ${text.trim()}") + } MPV_EVENT_PLAYBACK_RESTART -> { opening = false resumeAt?.let(::checkResumed) @@ -400,17 +417,57 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { // Appended one by one, so a comma inside a header value is never taken as a separator command("change-list", "http-header-fields", "clr", "") headers.forEach { (key, value) -> command("change-list", "http-header-fields", "append", "$key: $value") } - command("set", "start", startSeconds?.let { "+$it" } ?: "none") - resumeAt = startSeconds?.takeIf { it > 0 } // keep-open pauses at the end of a file, which would carry over to the next one command("set", "pause", "no") + audioUrls = link.audioTracks.map { it.url } + subtitlesAdded.clear() + reopens = 0 + load(link.url, startSeconds) + // A new file drops the subtitles added to the last one + subtitles.forEach { addSubtitle(it.file, it.title, it.select) } + } + + /** Opens [url], with the link's own audio tracks, at [startSeconds] or from the start */ + private fun load(url: String, startSeconds: Double?) { + command("set", "start", startSeconds?.let { "+$it" } ?: "none") + resumeAt = startSeconds?.takeIf { it > 0 } fileLoaded = false opening = true + stalledSince = 0 afterLoad.clear() - command("loadfile", link.url, "replace") - link.audioTracks.forEach { command("audio-add", it.url, "auto") } - // A new file drops the subtitles added to the last one - subtitles.forEach { addSubtitle(it.file, it.title, it.select) } + command("loadfile", url, "replace") + audioUrls.forEach { command("audio-add", it, "auto") } + } + + /** + * A seek past what is loaded asks the server for that part of the file. Some servers never answer, + * and mpv would then wait there for good, the spinner turning. Once it waited [SEEK_PATIENCE_MS], + * the file is opened again at that point, which asks again on a new connection, with the audio + * track and subtitle that were chosen. Called with every status + */ + fun recoverStalledSeek() = whenStarted { + val waiting = fileLoaded && (opening || property("seeking") == "yes") + if (!waiting) { + stalledSince = 0 + if (fileLoaded) reopens = 0 + return@whenStarted + } + val now = System.currentTimeMillis() + if (stalledSince == 0L) stalledSince = now + if (now - stalledSince < SEEK_PATIENCE_MS || reopens >= MAX_REOPENS) return@whenStarted + val url = currentUrl ?: return@whenStarted + val at = property("time-pos")?.toDoubleOrNull() ?: return@whenStarted + reopens++ + println("WARNING MpvPlayer: no answer at ${at.toInt()} s for ${(now - stalledSince) / 1000} s, opening the file again there") + val audio = property("aid") + val subtitle = property("sid") + val subtitles = subtitlesAdded.toList() + load(url, at) + whenLoaded { + subtitles.forEach { (file, title) -> command("sub-add", file.absolutePath, "auto", title) } + audio?.let { command("set", "aid", it) } + subtitle?.let { command("set", "sid", it) } + } } /** @@ -436,6 +493,7 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { /** Adds a subtitle file, shown when [select] is true */ fun addSubtitle(file: File, title: String, select: Boolean = true) = whenLoaded { + subtitlesAdded += file to title command("sub-add", file.absolutePath, if (select) "select" else "auto", title) } @@ -604,10 +662,15 @@ class MpvPlayer internal constructor(private val lib: MpvLibrary) { private companion object { const val MPV_EVENT_SHUTDOWN = 1 + const val MPV_EVENT_LOG_MESSAGE = 2 const val MPV_EVENT_END_FILE = 7 const val MPV_EVENT_FILE_LOADED = 8 const val MPV_EVENT_PLAYBACK_RESTART = 21 const val END_FILE_REASON_EOF = 0 const val END_FILE_REASON_ERROR = 4 + /** A slow but working server answered a far seek within 8 s in tests: 12 s leaves it room */ + const val SEEK_PATIENCE_MS = 12_000L + /** Opened again this often for one seek, after which the server is left to answer */ + const val MAX_REOPENS = 2 } } diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt index 819b905a67d..75b64b074d2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/player/PlayerScreen.kt @@ -321,6 +321,7 @@ fun PlayerScreen( var sourceSaved: String? = null while (true) { status = player.status() + player.recoverStalledSeek() val last = playedOnLink[1] if (last >= 0 && status.position > last && status.position - last < 2) playedOnLink[0] += status.position - last playedOnLink[1] = status.position