diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..1012efb56b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -253,6 +253,10 @@ + + Unit) { + if (updaterState.dialog == null) { + return + } + + val title: String + val body: @Composable () -> Unit + val confirmButton: @Composable () -> Unit + val dismissButton: @Composable () -> Unit + val cancelable: Boolean + + when (val state = updaterState.dialog.state) { + is GithubUpdateDialogState.Error -> { + // Only show error if it is initialized by the user + if (!updaterState.dialog.isFromUser) return + + cancelable = true + title = stringResource(R.string.download_failed) + body = { Text(text = state.error.getStackTracePretty()) } + confirmButton = { + WhiteButton(text = stringResource(R.string.check_for_update), onClick = { + onAction(SearchForUpdate) + }) + } + dismissButton = { + BlackButton(text = stringResource(R.string.ok), onClick = { + onAction(Dismiss) + }) + } + } + + GithubUpdateDialogState.Loading -> { + // Only show loading if it is initialized by the user + if (!updaterState.dialog.isFromUser) return + + cancelable = true + title = stringResource(R.string.loading) + body = { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + confirmButton = {} + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = { + onAction(Dismiss) + }) + } + } + + GithubUpdateDialogState.NoUpdateFound -> { + // Only show error if it is initialized by the user + if (!updaterState.dialog.isFromUser) return + + cancelable = true + title = stringResource(R.string.no_update_found) + body = {} + confirmButton = { + WhiteButton(text = stringResource(R.string.ok), onClick = { + onAction(Dismiss) + }) + } + dismissButton = {} + } + + is GithubUpdateDialogState.UpdateFound -> { + cancelable = true + title = stringResource( + R.string.new_update_format, + (state.oldSha ?: BuildConfig.VERSION_NAME), + (state.newSha ?: state.file.displayName) + ) + body = { Text(text = state.file.changeLog) } + confirmButton = { + WhiteButton(text = stringResource(R.string.update), onClick = { + onAction(Update(state.file)) + }) + } + dismissButton = { + BlackButton(text = stringResource(R.string.skip_update), onClick = { + onAction(SkipUpdate(state.file)) + onAction(Dismiss) + }) + BlackButton(text = stringResource(R.string.cancel), onClick = { + onAction(Dismiss) + }) + } + } + + is GithubUpdateDialogState.DownloadProgress -> { + cancelable = false + title = stringResource(R.string.update_notification_downloading) + body = { + if (state.total != null) { + val progress = + (state.progress.toFloat() / state.total.toFloat()).coerceIn(0.0f, 1.0f) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + // Round down the progress to avoid 100% + text = "${(progress * 100.0f).toInt()}% (${ + formatFileSize( + LocalContext.current, + state.progress + ) + } / ${ + formatFileSize( + LocalContext.current, + state.total + ) + })" + ) + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + progress = { progress } + ) + } + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + text = formatFileSize( + LocalContext.current, + state.progress + ) + ) + Spacer(modifier = Modifier.height(MaterialTheme.padding.medium)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onBackground, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + confirmButton = {} + dismissButton = { + BlackButton(text = stringResource(R.string.cancel), onClick = { + onAction(Dismiss) + }) + } + } + } + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { + if (cancelable) { + onAction(Dismiss) + } + }, + title = { Text(text = title) }, + text = body, + confirmButton = confirmButton, + dismissButton = dismissButton + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/receivers/PackageInstallerStatusReceiver.kt b/app/src/main/java/com/lagradost/cloudstream3/receivers/PackageInstallerStatusReceiver.kt new file mode 100644 index 00000000000..1739b81478a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/receivers/PackageInstallerStatusReceiver.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import com.lagradost.cloudstream3.utils.getSafeParcelableExtra + +/** https://medium.com/@solrudev/painless-building-of-an-android-package-installer-app-d5a09b5df432 */ +class PackageInstallerStatusReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + intent.getSafeParcelableExtra(Intent.EXTRA_INTENT)?.let { userAction-> + userAction.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(userAction) + } + } + PackageInstaller.STATUS_SUCCESS -> { + // do something on success + } + else -> { + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + println("PackageInstallerStatusReceiver: status=$status, message=$message") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt new file mode 100644 index 00000000000..08e279b49a0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/ApkUpdater.kt @@ -0,0 +1,220 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import androidx.core.content.FileProvider +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.receivers.PackageInstallerStatusReceiver +import com.lagradost.cloudstream4.AppSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.security.DigestException +import java.security.MessageDigest + +object ApkUpdater : AppUpdater { + private const val APP_UPDATE_NAME = "CloudStream" + private const val APP_UPDATE_SUFFIX = "apk" + + @Throws + override suspend fun update( + settings: AppSettings, + url: String, + digest: DigestPair?, + downloadProgress: (Long, Long?) -> Unit + ) { + val activity = CommonActivity.activity ?: throw ErrorLoadingException("No activity found") + clearOldFiles(activity) + + val request = app.get(url) + val length = request.size + val body = request.body + body.use { body -> + val length = length ?: body.contentLength() + val readStream = body.byteStream() + + when (settings.updates.apkInstaller.get()) { + 0 -> { + packageInstallerDownloader( + activity, + readStream, + length, + digest, + downloadProgress + ) + } + + else -> { + legacyDownloader(activity, readStream, length, digest, downloadProgress) + } + } + } + } + + fun clearOldFiles(activity: Activity) { + // Delete old files + activity.cacheDir.listFiles()?.filter { + it.name.startsWith(APP_UPDATE_NAME) && it.extension == APP_UPDATE_SUFFIX + }?.forEach { + deleteFileOnExit(it) + } + } + + /** https://medium.com/@solrudev/painless-building-of-an-android-package-installer-app-d5a09b5df432 */ + @SuppressLint("RequestInstallPackagesPolicy") + @Throws + suspend fun packageInstallerDownloader( + activity: Activity, + readStream: InputStream, + length: Long?, + digest: DigestPair?, + downloadProgress: (Long, Long?) -> Unit, + ) = withContext(Dispatchers.IO) { + var sessionId: Int? = null + val packageInstaller = activity.packageManager.packageInstaller + try { + val installParams = + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + if (length != null) { + installParams.setSize(length) + } + + sessionId = packageInstaller.createSession(installParams) + val session = packageInstaller.openSession(sessionId) + + // We do not need to buffer this because transfer has large writes + session.openWrite(activity.packageName, 0, length ?: -1L) + .use { writeStream -> + transfer(writeStream, readStream, length, downloadProgress, digest) + session.fsync(writeStream) + } + + val receiverIntent = Intent(activity, PackageInstallerStatusReceiver::class.java) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val receiverPendingIntent = + PendingIntent.getBroadcast(activity, 0, receiverIntent, flags) + + // Avoid delayed updates, and just commit instantly + session.commit(receiverPendingIntent.intentSender) + session.close() + } catch (t: Throwable) { + sessionId?.let { sessionId -> + packageInstaller.abandonSession(sessionId) + } + throw t + } + } + + /** + * Write the "readStream" to the "writeStream", while notifying the "downloadProgress" and + * calculating the digest + * + * ------------------------------------------------------------------------------------------ + * + * throws a DigestException if the Digest can be calculated (non-null + correct algorithm), + * and is mismatched + * + * throws a CancellationException if canceled, but may not be "instant" if the read is blocking + * + * throws IOException and similar for reading + * + * ------------------------------------------------------------------------------------------ + * + * In case of crashes from the digest, we ignore it as we do not want to "block" someone from + * updating if they got a broken OS without the desired algorithm or implementation. + * + * This is because a malicious CDN should not be able to "crash" the algorithm, but a broken + * OS will. And recovering from a broken OS by ignoring it is better than refusing it. + * */ + @Throws + suspend fun transfer( + writeStream: OutputStream, + readStream: InputStream, + length: Long?, + downloadProgress: (Long, Long?) -> Unit, + digest: DigestPair?, + ) = withContext(Dispatchers.IO) { + val md = digest?.algorithm?.let { digestAlgorithm -> + safe { + MessageDigest.getInstance(digestAlgorithm) + } + } + + val context = currentCoroutineContext() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + var transferred: Long = 0 + while ((readStream.read(buffer, 0, DEFAULT_BUFFER_SIZE) + .also { read = it }) >= 0 + ) { + writeStream.write(buffer, 0, read) + + safe { + md?.update(buffer, 0, read) + } + + transferred += read.toLong() + downloadProgress(transferred, length) + context.ensureActive() + } + writeStream.flush() + + val check = safe { md?.digest() } + if (check != null && !check.contentEquals(digest?.digest)) { + throw DigestException("Mismatched digest on transfer ${digest?.algorithm} : ${check.toHexString()} / ${digest?.digest?.toHexString()}") + } + } + + @Throws + suspend fun legacyDownloader( + activity: Activity, + readStream: InputStream, + length: Long?, + digest: DigestPair?, + downloadProgress: (Long, Long?) -> Unit + ) = withContext(Dispatchers.IO) { + val downloadedFile = File.createTempFile(APP_UPDATE_NAME, ".$APP_UPDATE_SUFFIX") + + // We do not need to buffer this because transfer has large writes + downloadedFile.outputStream().use { writeStream -> + transfer(writeStream, readStream, length, downloadProgress, digest) + } + + openApk(activity, downloadedFile) + } + + fun openApk(context: Context, file: File) { + val contentUri = FileProvider.getUriForFile( + context, BuildConfig.APPLICATION_ID + ".provider", file + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt new file mode 100644 index 00000000000..94e0ebf4a60 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubReleases.kt @@ -0,0 +1,117 @@ +package com.lagradost.cloudstream3.ui.settings + +import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.jvm.Throws + +object GithubReleases { + @Serializable + private data class GithubAsset( + @JsonProperty("name") @SerialName("name") val name: String, + @JsonProperty("size") @SerialName("size") val size: Int, // Size in bytes + @JsonProperty("browser_download_url") @SerialName("browser_download_url") val browserDownloadUrl: String, + @JsonProperty("content_type") @SerialName("content_type") val contentType: String, // application/vnd.android.package-archive + @JsonProperty("digest") @SerialName("digest") val digest: String? = null, // sha256:..., may be null + ) + + @Serializable + private data class GithubRelease( + @JsonProperty("tag_name") @SerialName("tag_name") val tagName: String, // Version code + @JsonProperty("body") @SerialName("body") val body: String, // Description + @JsonProperty("assets") @SerialName("assets") val assets: List, + @JsonProperty("target_commitish") @SerialName("target_commitish") val targetCommitish: String, // Branch + @JsonProperty("prerelease") @SerialName("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") @SerialName("node_id") val nodeId: String, + @JsonProperty("created_at") @SerialName("created_at") val createdAt: String, // YYYY-MM-DDTHH:MM:SSZ + ) + + @Serializable + private data class GithubObject( + @JsonProperty("sha") @SerialName("sha") val sha: String, // SHA-256 hash + //@JsonProperty("type") @SerialName("type") val type: String, + ///@JsonProperty("url") @SerialName("url") val url: String, + ) + + @Serializable + private data class GithubTag( + //@JsonProperty("node_id") @SerialName("node_id") val nodeId: String, + @JsonProperty("object") @SerialName("object") val githubObject: GithubObject, + ) + + /** GitHub file update package */ + @Immutable + data class GithubFile( + /** File digest, sha:xxx */ + val digest: String?, + /** File url for download */ + val downloadUrl: String, + /** Filename without the extension */ + val displayName: String, + /** Changelog, aka the commit message */ + val changeLog: String, + /** Name of the tag, aka unique release name like vX.X.X or pre-release */ + val tagName: String, + /** Unique node id */ + val nodeId: String, + ) + + private val defaultHeaders = mapOf("Accept" to "application/vnd.github.v3+json") + + @Throws + suspend fun getShaFromTag( + userName: String, + repository: String, + tag: String, + ): String { + return app.get( + url = "https://api.github.com/repos/$userName/$repository/git/ref/tags/$tag", + headers = defaultHeaders + ).parsed().githubObject.sha + } + + @Throws + suspend fun getLatestReleaseFile( + userName: String, + repository: String, + prerelease: Boolean, + prereleaseTag: String, + contentType: String, + ): GithubFile? { + val latestReleaseUrl = if (prerelease) { + // Find the release object + // https://docs.github.com/en/rest/releases/releases?apiVersion=2026-03-10#get-a-release-by-tag-name + "https://api.github.com/repos/$userName/$repository/releases/tags/$prereleaseTag" + } else { + // Just get the latest release + // https://docs.github.com/en/rest/releases/releases?apiVersion=2026-03-10#get-the-latest-release + // The latest release is the most recent non-prerelease, non-draft release, sorted by the created_at attribute + "https://api.github.com/repos/$userName/$repository/releases/latest" + } + + val latestRelease = app.get( + url = latestReleaseUrl, + headers = defaultHeaders + ).parsed() + + // Find the first correct asset, given that we might have other binaries we release in the same version + val foundAsset = latestRelease.assets.firstOrNull { asset -> + asset.contentType == contentType + } + + if (foundAsset == null) { + return null + } + + return GithubFile( + digest = foundAsset.digest, + downloadUrl = foundAsset.browserDownloadUrl, + displayName = foundAsset.name.substringBeforeLast("."), + changeLog = latestRelease.body, + tagName = latestRelease.tagName, + nodeId = latestRelease.nodeId + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt new file mode 100644 index 00000000000..99236ab7173 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/GithubViewModel.kt @@ -0,0 +1,299 @@ +package com.lagradost.cloudstream3.ui.settings + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.SingleActiveQuery +import com.lagradost.cloudstream4.compose.StateContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.FileNotFoundException + +@Immutable +data class GithubState( + val dialog: GithubDialog? = null, +) + +@Immutable +sealed class GithubUpdateDialogState { + data class Error(val error: Throwable) : GithubUpdateDialogState() + data class DownloadProgress(val progress: Long, val total: Long?) : GithubUpdateDialogState() + object Loading : GithubUpdateDialogState() + object NoUpdateFound : GithubUpdateDialogState() + data class UpdateFound( + val file: GithubReleases.GithubFile, + val newSha: String?, + val oldSha: String?, + ) : GithubUpdateDialogState() +} + +@Immutable +data class GithubDialog( + val isPrerelease: Boolean, + val isFromUser: Boolean, + val state: GithubUpdateDialogState, +) + +@Immutable +sealed class GithubAction { + object AutoSearchForUpdate : GithubAction() + object SearchForUpdate : GithubAction() + object Dismiss : GithubAction() + data class SkipThisUpdate(val file: GithubReleases.GithubFile) : GithubAction() + data class Update(val file: GithubReleases.GithubFile) : GithubAction() + data class SkipUpdate(val file: GithubReleases.GithubFile) : GithubAction() +} + +const val APK_USERNAME = "recloudstream" +const val APK_REPOSITORY = "cloudstream" +const val APK_PRERELEASE = "pre-release" +const val APK_CONTENT_TYPE = "application/vnd.android.package-archive" + +interface AppUpdater { + @Throws + suspend fun update( + settings: AppSettings, + url: String, + digest: DigestPair?, + downloadProgress: (Long, Long?) -> Unit, + ) +} + +/** The digest pair to verify that a file is correctly downloaded */ +data class DigestPair( + val algorithm : String, + val digest : ByteArray, +) { + companion object { + // "sha256:XXXX" or "sha256-XXXX" -> "sha256", bytearray(XXXX) + fun parse(digestPair: String?) : DigestPair? { + if(digestPair == null) return null + + var split = digestPair.split(":", limit = 2) + if(split.size != 2) { + split = digestPair.split("-", limit = 2) + } + if(split.size != 2) { + return null + } + + val digestAlgorithm = split.getOrNull(0) ?: return null + val digestByteArray = safe { split.getOrNull(1)?.hexToByteArray() } ?: return null + + return DigestPair(digestAlgorithm, digestByteArray) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + val other = other as? DigestPair ?: return false + + if (algorithm != other.algorithm) return false + if (!digest.contentEquals(other.digest)) return false + + return true + } + + override fun hashCode(): Int { + var result = algorithm.hashCode() + result = 31 * result + digest.contentHashCode() + return result + } +} + +/** + * Cross-platform downloader for updates served by GitHub. + * + * To allow this Cross-platform behavior work we split up the UI from the Viewmodel, + * and the Viewmodel from the installer. + * ``` + * UI: Renders the current viewmodel state + * Viewmodel: Searches for updates on GitHub + * AppUpdater: Installs the update from the raw GitHub url + * + * UI -(onAction)-> Viewmodel -(invokes)-> AppUpdater + * <---(state)---/ <--(callback)--/ + * ``` + * */ +class GithubViewModel( + val remoteUserName: String, + val remoteRepository: String, + val remotePrereleaseTag: String, + val remoteContentType: String, + val versionName: String, + val isPrerelease: Boolean, + val isDebug: Boolean, + val buildSha: String, + val settings: AppSettings, + val updater: AppUpdater, +) : ViewModel(), StateContainer by DefaultStateContainer(GithubState()), + ActionHandler { + private val updateDispatcher = SingleActiveQuery(Dispatchers.IO) + + override fun onAction(action: GithubAction) { + when (action) { + GithubAction.SearchForUpdate -> { + ioSafe { + searchForUpdate(prerelease = isPrerelease, fromUser = true) + } + } + + GithubAction.Dismiss -> { + viewModelScope.launch { + updateDispatcher.cancel() + updateState { copy(dialog = null) } + } + } + + is GithubAction.SkipThisUpdate -> { + settings.updates.skipUpdate.set(action.file.nodeId) + } + + GithubAction.AutoSearchForUpdate -> { + if (!isDebug && settings.updates.showAppUpdates.get()) { + ioSafe { + searchForUpdate(prerelease = isPrerelease, fromUser = false) + } + } + } + + is GithubAction.SkipUpdate -> { + settings.updates.skipUpdate.set(action.file.nodeId) + } + + is GithubAction.Update -> { + ioSafe { + installUpdate(action.file.downloadUrl, action.file.digest) + } + } + } + } + + /** Cancel the old update, and catch possible errors from the block and show as a new state */ + private suspend fun dispatchUpdate(block: /* @Throws */ suspend () -> Unit) { + updateDispatcher.launch { + try { + block() + } catch (t: Throwable) { + // If it was canceled ignore it as we probably launched another update check + if (!isActive) { + return@launch + } + // Otherwise we display the error + updateState { + copy(dialog = dialog?.copy(state = GithubUpdateDialogState.Error(t))) + } + } + } + } + + private suspend fun installUpdate(url: String, digestPair: String?) = dispatchUpdate { + updater.update( + settings = settings, + url = url, + digest = DigestPair.parse(digestPair), + ) { progress, total -> + updateState { + copy( + dialog = dialog?.copy( + state = GithubUpdateDialogState.DownloadProgress( + progress = progress, + total = total + ) + ) + ) + } + } + updateState { + copy( + dialog = null + ) + } + } + + private suspend fun searchForUpdate( + prerelease: Boolean, + fromUser: Boolean, + ) = dispatchUpdate { + val baseDialog = GithubDialog( + isPrerelease = prerelease, + isFromUser = fromUser, + state = GithubUpdateDialogState.Loading + ) + + updateState { + copy(dialog = baseDialog) + } + + // If on pre-release check if the sha matches, as we do not look at the version + var oldSha: String? = null + var newSha: String? = null + if (prerelease) { + val sha = getSha(remotePrereleaseTag) + oldSha = buildSha.take(7) + newSha = sha.take(7) + + // Only match the first 7 chars, as that is what is saved + if (oldSha == newSha) { + updateState { + copy(dialog = baseDialog.copy(state = GithubUpdateDialogState.NoUpdateFound)) + } + return@dispatchUpdate + } + } + + val release = getRelease(prerelease) + + // If on stable, only check that the display name matches + if (!prerelease && release.displayName == versionName) { + updateState { + copy(dialog = baseDialog.copy(state = GithubUpdateDialogState.NoUpdateFound)) + } + return@dispatchUpdate + } + + // If this was automated, and we have pressed "skip this update" then check the node-id + if (!fromUser && release.nodeId == settings.updates.skipUpdate.get()) { + updateState { + copy(dialog = baseDialog.copy(state = GithubUpdateDialogState.NoUpdateFound)) + } + return@dispatchUpdate + } + + updateState { + copy( + dialog = baseDialog.copy( + state = GithubUpdateDialogState.UpdateFound( + file = release, + newSha = newSha, + oldSha = oldSha + ) + ) + ) + } + } + + @Throws + private suspend fun getRelease(prerelease: Boolean) = + GithubReleases.getLatestReleaseFile( + prerelease = prerelease, + userName = remoteUserName, + repository = remoteRepository, + prereleaseTag = remotePrereleaseTag, + contentType = remoteContentType + ) ?: throw FileNotFoundException() + + @Throws + private suspend fun getSha(tag: String) = + GithubReleases.getShaFromTag( + tag = tag, + userName = remoteUserName, + repository = remoteRepository, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt index 3c90e8e3684..091a645d6d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Intent import android.net.Uri -import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @@ -18,7 +17,7 @@ import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.activity -import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivityScreen import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PluginManager @@ -26,7 +25,6 @@ import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded -import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream4.AppSettings import com.lagradost.cloudstream4.rememberAppSettings @@ -81,6 +79,8 @@ object SettingsUpdatesScreen : SearchableSettings { } } + val githubViewModel = MainActivityScreen.githubViewModel() + return persistentListOf( Preference.PreferenceGroup( title = stringResource(R.string.pref_category_app_updates), @@ -90,7 +90,8 @@ object SettingsUpdatesScreen : SearchableSettings { subtitle = BuildConfig.VERSION_NAME, icon = painterResource(R.drawable.mobile_arrow_down_24px), onClick = { - ioSafe { + githubViewModel?.onAction(GithubAction.SearchForUpdate) + /*ioSafe { if (activity?.runAutoUpdate(false) == false) { activity?.runOnUiThread { showToast( @@ -99,7 +100,7 @@ object SettingsUpdatesScreen : SearchableSettings { ) } } - } + }*/ } ), Preference.PreferenceItem.TextPreference( diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2483a37145c..eb7c67145b3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -82,4 +82,9 @@ tools:ignore="FragmentTagUsage" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main_tv.xml b/app/src/main/res/layout/activity_main_tv.xml index c51d77d162b..0d81285a62f 100644 --- a/app/src/main/res/layout/activity_main_tv.xml +++ b/app/src/main/res/layout/activity_main_tv.xml @@ -93,6 +93,11 @@ + + diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0008a854d86..539c51d36f5 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -11,11 +11,15 @@ kotlin { sourceSets { jvmMain.dependencies { implementation(libs.bundles.compose) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.kotlinx.collections.immutable) implementation(compose.desktop.currentOs) { // compose.desktop.currentOs imports the wrong material 2, so we exclude it exclude(group = "org.jetbrains.compose.material", module = "material") } implementation(project(":shared")) + implementation(project(":library")) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4652cf938c1..c6f0ee55ae2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -79,6 +79,7 @@ annotation = { module = "androidx.annotation:annotation", version.ref = "annotat appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } 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" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } colorpicker = { module = "com.github.recloudstream:color-picker-android", version.ref = "colorpicker" } conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscryptAndroid" } @@ -103,12 +104,16 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinGradlePlugin" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIOCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } ktor-http = { module = "io.ktor:ktor-http", version.ref = "ktor" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt index 9e6cb99e5be..0f33a6ff6e8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/ParCollections.kt @@ -122,4 +122,27 @@ suspend fun runAllAsync( } } }.map { it.await() } -} \ No newline at end of file +} + +/** amap with stronger cancellation guarantee, aka will only return when each job is joined */ +@OptIn(DelicateCoroutinesApi::class) +@InternalAPI +@Throws +suspend fun Collection.cmap(f: suspend (A) -> B): List = + with(CoroutineScope(currentCoroutineContext())) { + // 1. Spawn all jobs + map { async { f(it) } }.map { deferred -> + // 2. Await all jobs without throwing, and join if canceled + try { + Result.success(deferred.await()) + } catch (e: CancellationException) { + withContext(NonCancellable) { + deferred.join() + } + Result.failure(e) + } catch (t : Throwable) { + Result.failure(t) + } + // 3. Throw if something goes wrong + }.map { it.getOrThrow() } + } \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 2bc9150d077..6bca3488e24 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { } commonMain.dependencies { + implementation(libs.coil.network.ktor3) implementation(libs.bundles.compose) implementation(libs.kotlinx.collections.immutable) implementation(project(":library")) @@ -41,6 +42,15 @@ kotlin { androidMain.dependencies { implementation(libs.activity.compose) implementation(libs.preference.ktx) + implementation(libs.ktor.client.android) + } + + appleMain.dependencies { + implementation(libs.ktor.client.darwin) + } + + jvmMain.dependencies { + implementation(libs.ktor.client.java) } } } diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 6d81554c1ab..e5eca5dd287 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -7,6 +7,7 @@ Transparency Luminance Color + Color Wheel 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. diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index c0f784fc13f..168b8259028 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -45,6 +45,8 @@ class BackupPreferences(preferences: PreferenceStore) { class UpdatePreferences(preferences: PreferenceStore) { val apkInstaller = preferences.getInt("apk_installer_key",1) val showAppUpdates = preferences.getBoolean("auto_update", true) + // Node id for this update + val skipUpdate = preferences.getString("skip_update_key", "") } class SecurityPreferences(preferences: PreferenceStore) { diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt index a89085556bb..c1abc206dd9 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -34,9 +35,12 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import com.lagradost.cloudstream4.compose.colorpicker.DoubleColorPicker +import com.lagradost.cloudstream4.compose.colorpicker.HsvColor import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.cancel import com.lagradost.cloudstream4.generated.resources.color +import com.lagradost.cloudstream4.generated.resources.color_wheel import com.lagradost.cloudstream4.generated.resources.luminance import com.lagradost.cloudstream4.generated.resources.ok import com.lagradost.cloudstream4.generated.resources.transparency @@ -84,7 +88,9 @@ fun ColorDialog( confirm: (Color) -> Unit, ) { var alpha by remember { mutableFloatStateOf(color.alpha) } - var selectedColor by remember { mutableStateOf(color) } + var selectedColor by remember { mutableStateOf(HsvColor(color)) } + val renderedColor = selectedColor.toColor().copy(alpha = alpha) + var colorWheel by remember { mutableStateOf(false) } AlertDialog( properties = DialogProperties(usePlatformDefaultWidth = false), @@ -93,7 +99,7 @@ fun ColorDialog( title = { Row(verticalAlignment = Alignment.CenterVertically) { ColorCircle( - color = selectedColor.copy(alpha = alpha), + color = renderedColor, ) Spacer(modifier = Modifier.size(MaterialTheme.padding.medium)) Text(text = title) @@ -111,50 +117,70 @@ fun ColorDialog( modifier = Modifier.padding(MaterialTheme.padding.extraSmall), style = MaterialTheme.typography.titleMedium ) - FlowRow { - ColorCircle( - color = Color.White.copy(alpha = alpha), + if (colorWheel) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(10.dp) ) { - selectedColor = Color.White + Spacer(modifier = Modifier.weight(1.0f)) + DoubleColorPicker( + color = { selectedColor }, + onColorChange = { selectedColor = it }, + modifier = Modifier.size(200.dp), + ringStrokeWidth = 20.dp, + ) + Spacer(modifier = Modifier.weight(1.0f)) } - - val hsl = floatArrayOf(0.0f, 1.0f, 0.5f) - (0..18).forEach { hue -> - hsl[0] = hue.toFloat() * 18f - val color = hslToColor(hsl) + } else { + FlowRow { ColorCircle( - color = color.copy(alpha = alpha), + color = Color.White.copy(alpha = alpha), ) { - selectedColor = color + selectedColor = HsvColor(Color.White) } - } - colors2.forEach { color -> - ColorCircle( - color = color.copy(alpha = alpha), - ) { - selectedColor = color + + val hsl = floatArrayOf(0.0f, 1.0f, 0.5f) + (0..18).forEach { hue -> + hsl[0] = hue.toFloat() * 18f + val color = hslToColor(hsl) + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = HsvColor(color) + } + } + colors2.forEach { color -> + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = HsvColor(color) + } } } - } + Text( + text = stringResource(Res.string.luminance), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(MaterialTheme.padding.extraSmall), + style = MaterialTheme.typography.titleMedium + ) - Text( - text = stringResource(Res.string.luminance), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(MaterialTheme.padding.extraSmall), - style = MaterialTheme.typography.titleMedium - ) - - // https://github.com/mhssn95/compose-color-picker/blob/main/colorPicker/src/main/java/io/mhssn/colorpicker/ext/drawExt.kt - Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { - val hsl = floatArrayOf(0.0f, 0.0f, 0.0f) - (0..10).forEach { luminance -> - rbgToHSL(selectedColor.red, selectedColor.green, selectedColor.blue, hsl) - hsl[2] = luminance.toFloat() * 0.1f - val color = hslToColor(hsl) - ColorCircle( - color = color.copy(alpha = alpha), - ) { - selectedColor = color + // https://github.com/mhssn95/compose-color-picker/blob/main/colorPicker/src/main/java/io/mhssn/colorpicker/ext/drawExt.kt + Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { + val hsl = floatArrayOf(0.0f, 0.0f, 0.0f) + (0..10).forEach { luminance -> + rbgToHSL( + selectedColor.red, + selectedColor.green, + selectedColor.blue, + hsl + ) + hsl[2] = luminance.toFloat() * 0.1f + val color = hslToColor(hsl) + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = HsvColor(color) + } } } } @@ -180,9 +206,13 @@ fun ColorDialog( confirmButton = { WhiteButton( text = stringResource(Res.string.ok), - onClick = { confirm(selectedColor.copy(alpha = alpha)) }) + onClick = { confirm(renderedColor) }) }, dismissButton = { + BlackButton(text = stringResource(Res.string.color_wheel), onClick = { + colorWheel = !colorWheel + }) + BlackButton(text = stringResource(Res.string.cancel), onClick = dismiss) }) } diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt index 8255b426023..1320fcf8e68 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt @@ -106,6 +106,14 @@ data class SingleActiveQuery( private var job: Job? = null, private val mutex: Mutex = Mutex(), ) { + suspend fun cancel() { + mutex.withLock { + job?.cancel() + job?.join() + job = null + } + } + suspend fun launch(block: suspend CoroutineScope.() -> Unit) { val currentScope = CoroutineScope(currentCoroutineContext()) val obj: suspend CoroutineScope.() -> Unit = { diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/CircularColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/CircularColorPicker.kt new file mode 100644 index 00000000000..bdae7c0aaa6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/CircularColorPicker.kt @@ -0,0 +1,212 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.toOffset +import kotlinx.coroutines.launch +import kotlin.math.* + +/** + * Circular color picker that allows the user to select a hue by dragging a thumb around the circle. + * The color is represented in HSV color space with a fixed value. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * @param thumb Composable that is used to draw the thumb + * + * @see RingColorPicker + * @see SquareColorPicker + */ +@Composable +fun CircularColorPicker( + color: () -> HsvColor, + onColorChange: (HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onColorChangeFinished: () -> Unit = {}, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), interactionSource) + } +) { + CircularColorPicker( + hue = { color().hue }, + saturation = { color().saturation }, + value = { color().value }, + onColorChange = { h, s -> onColorChange(color().copy(hue = h, saturation = s)) }, + modifier = modifier, + interactionSource = interactionSource, + onColorChangeFinished = onColorChangeFinished, + thumb = thumb + ) +} + +/** + * Circular color picker that allows the user to select a hue by dragging a thumb around the circle. + * The color is represented in HSV color space with a fixed value. + * + * @param hue The hue of the color + * @param saturation The saturation of the color + * @param value The value of the color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * @param thumb Composable that is used to draw the thumb + * + * @see RingColorPicker + * @see SquareColorPicker + */ +@Composable +fun CircularColorPicker( + hue: () -> Float, + saturation: () -> Float, + modifier: Modifier = Modifier, + value: () -> Float = { 1f }, + onColorChange: (hue: Float, saturation: Float) -> Unit, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onColorChangeFinished: () -> Unit = {}, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(hue(), saturation(), value()), interactionSource) + } +) { + val scope = rememberCoroutineScope() + var radius by remember { mutableFloatStateOf(0f) } + + val currentOnColorChange by rememberUpdatedState(onColorChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { + radius = it.width / 2f + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val downPosition = down.position + val center = size.center.toOffset() + + // Check if initial touch is within the circle + if ((downPosition - center).getDistanceSquared() > radius * radius) return@awaitEachGesture + + // Handle initial tap + updateColorFromPosition(downPosition, center, radius, currentOnColorChange) + + // Start drag interaction + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + val adjustedPosition = clampPositionToRadius(change.position, center, radius) + updateColorFromPosition(adjustedPosition, center, radius, currentOnColorChange) + } + + // Continue dragging + while (change != null && change.pressed) { + change.consume() + val adjustedPosition = clampPositionToRadius(change.position, center, radius) + updateColorFromPosition(adjustedPosition, center, radius, currentOnColorChange) + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .drawWithCache { + val v = value() + val hueBrush = Brush.sweepGradient( + colors = List(7) { i -> + Color.hsv( + hue = i * 60f, + saturation = 1f, + value = v + ) + } + ) + val saturationBrush = Brush.radialGradient( + listOf(Color.hsv(0f, 0f, v), Color.Transparent) + ) + + onDrawBehind { + drawCircle(hueBrush) + drawCircle(saturationBrush) + } + } + ) { + Box( + modifier = Modifier.offset { + val angle = hue() * DEG_TO_RAD + val distance = saturation() * radius + + IntOffset( + x = (radius + distance * cos(angle)).roundToInt(), + y = (radius + distance * sin(angle)).roundToInt() + ) + } + ) { + thumb() + } + } +} + +/** + * Get the color for a given position in the circular color picker. + * + * @return A pair of hue and saturation values, or null if the position is outside the circle. + */ +private inline fun updateColorFromPosition( + position: Offset, + center: Offset, + radius: Float, + onResult: (hue: Float, saturation: Float) -> Unit +) { + val offset = position - center + + val degrees = atan2(offset.y, offset.x) * RAD_TO_DEG + val centerAngle = (degrees + 360) % 360 + val distance = offset.getDistance() + val saturation = (distance / radius).coerceIn(0f, 1f) + + onResult(centerAngle, saturation) +} + +/** + * Clamp the position to the radius of the color picker circle. + * + * @return The clamped position that lies within the circle of the given radius. + */ +private fun clampPositionToRadius(position: Offset, center: Offset, radius: Float): Offset { + val offset = position - center + + // If the position is already within the radius, return it as is + if (offset.getDistanceSquared() <= radius * radius) return position + + // Otherwise, clamp the position to the edge of the circle + val scale = radius / offset.getDistance() + return center + (offset * scale) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/ColorPickerDefaults.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/ColorPickerDefaults.kt new file mode 100644 index 00000000000..9cc25a0aaf3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/ColorPickerDefaults.kt @@ -0,0 +1,64 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.PI + +internal const val DEG_TO_RAD = (PI / 180).toFloat() +internal const val RAD_TO_DEG = (180 / PI).toFloat() + +internal val ThumbRadiusPressed = 14.dp +internal val ThumbRadius = 10.dp + +object ColorPickerDefaults { + /** + * The default size applied to color pickers. Note that you can override it by applying + * Modifier.size directly on a picker component. + */ + val ComponentSize: Dp = 128.dp + + /** + * Default implementation of the thumb component for the color pickers. + * + * @param color The color of the thumb + * @param interactionSource The interaction source for the thumb + * @param modifier The modifier for the thumb + */ + @Composable + fun Thumb(color: Color, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier) { + val isPressed by interactionSource.collectIsPressedAsState() + val isDragged by interactionSource.collectIsDraggedAsState() + val radius by animateDpAsState( + targetValue = if (isPressed || isDragged) ThumbRadiusPressed else ThumbRadius + ) + + Canvas(modifier = modifier) { + drawCircle(color, radius = radius.toPx()) + + // Draw the border + drawCircle( + color = color.contrastingColor, + radius = radius.toPx(), + alpha = 0.5f, + style = Stroke(1.dp.toPx()) + ) + } + } +} + +internal val Color.contrastingColor: Color + get() = if (this.isDark()) Color.White else Color.Black + +internal fun Color.isDark(): Boolean { + return 0.2126 * this.red + 0.7152 * this.green + 0.0722 * this.blue < .5f +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/DoubleColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/DoubleColorPicker.kt new file mode 100644 index 00000000000..e5b07d7b125 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/DoubleColorPicker.kt @@ -0,0 +1,208 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.* +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +@Composable +fun DoubleColorPicker( + color: () -> HsvColor, + onColorChange: (color: HsvColor) -> Unit, + modifier: Modifier = Modifier, + ringStrokeWidth: Dp = 16.dp, + innerPadding: Dp = 50.dp, + ringInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + ringThumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(color().hue, 1f, 1f), ringInteractionSource) + }, + innerInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + innerThumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), innerInteractionSource) + }, +) { + Box(modifier) { + RingColorPicker( + interactionSource = ringInteractionSource, + thumb = ringThumb, + ringStrokeWidth = ringStrokeWidth, + color = color, + onColorChange = onColorChange, + modifier = Modifier.fillMaxSize() + ) + Box(Modifier.fillMaxSize().padding(innerPadding)) { + CircularSquareColorPicker( + interactionSource = innerInteractionSource, + thumb = innerThumb, + color = color, + onColorChange = onColorChange, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +/** + * The standard circle color picker that allows the user to select a color by dragging a thumb around the color space. + * + * The color is represented in HSV color space with a fixed hue. The saturation and value can be controlled by + * dragging the thumb. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param thumb Composable that is used to draw the thumb + * @param shape The shape of the color picker, note that the corner radius should be kept small, + * to prevent the thumb from visually appearing outside the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see RingColorPicker + */ +@Composable +fun CircularSquareColorPicker( + color: () -> HsvColor, + onColorChange: (color: HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), interactionSource) + }, + onColorChangeFinished: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + var size by remember { mutableStateOf(IntSize.Zero) } + + val currentColor by rememberUpdatedState(color) + val currentOnColorChange by rememberUpdatedState(onColorChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box { + Canvas( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { size = it } + .clip(CircleShape) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + + hsvColorForPosition(down.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + + // Start drag interaction + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + } + + // Continue dragging + while (change != null && change.pressed) { + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + val saturationBrush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + val hueBrush = Brush.horizontalGradient( + listOf( + Color.Transparent, + Color.hsv(currentColor().hue, 1f, 1f) + ) + ) + + drawRect(Color.White) + drawRect(hueBrush) + drawRect(saturationBrush) + } + + Box( + modifier = Modifier.offset { + val x = -1.0 + currentColor().saturation * 2.0 + val y = 1.0 - currentColor().value * 2.0 + + // https://squircular.blogspot.com/2015/09/mapping-circle-to-square.html + val u = x * sqrt(1 - y * y * 0.5) + val v = y * sqrt(1 - x * x * 0.5) + + IntOffset( + x = (u * size.width * 0.5 + size.width * 0.5).roundToInt(),//(currentColor().saturation * size.width).roundToInt(), + y = (v * size.height * 0.5 + size.height * 0.5).roundToInt(), //(size.height - currentColor().value * size.height).roundToInt() + ) + } + ) { + thumb() + } + } +} + +private fun hsvColorForPosition(position: Offset, size: IntSize): Pair { + val clampedX = position.x / size.width + val clampedY = position.y / size.height + + val x = -1.0 + clampedX * 2.0 + val y = 1.0 - clampedY * 2.0 + + // coerce here to prevent drag from feeling like a square + val radius = sqrt(x * x + y * y).coerceIn(0.0, 1.0) + val angle = atan2(y, x) + + val u = cos(angle) * radius + val v = sin(angle) * radius + + val u2 = u * u + val v2 = v * v + val sqrt22 = 2.0 * sqrt(2.0) + + // https://squircular.blogspot.com/2015/09/mapping-circle-to-square.html + val xOut = 0.5 * sqrt(2 + sqrt22 * u + u2 - v2) - 0.5 * sqrt(2 - sqrt22 * u + u2 - v2) + val yOut = 0.5 * sqrt(2 + sqrt22 * v - u2 + v2) - 0.5 * sqrt(2 - sqrt22 * v - u2 + v2) + + return (xOut * 0.5 + 0.5).toFloat() to (yOut * 0.5 + 0.5).toFloat() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/HsvColor.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/HsvColor.kt new file mode 100644 index 00000000000..0fcbbda992b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/HsvColor.kt @@ -0,0 +1,170 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.toArgb +import kotlin.jvm.JvmInline +import kotlin.math.max + +/** + * Represents a color in the HSV (Hue, Saturation, Value) color space. + * + * This class provides properties to access the hue, saturation, and value of the color, + * as well as methods to convert to and from RGB color space. + */ +@Immutable +@JvmInline +value class HsvColor private constructor(val packedValue: ULong) { + /** + * The hue of the color, in degrees (0-360). + */ + @Stable + val hue: Float + get() = (packedValue shr 40 and 0xFFFFu).toFloat() / 100f + + /** + * The saturation of the color (0-1). + */ + @Stable + val saturation: Float + get() = (packedValue shr 20 and 0xFFFFFu).toFloat() / 1000000f + + /** + * The value (brightness) of the color (0-1). + */ + @Stable + val value: Float + get() = (packedValue and 0xFFFFFu).toFloat() / 1000000f + + /** + * The red component of the color (0-1). + */ + @Stable + val red: Float + get() = hsvToRgbComponent(5, hue, saturation, value) + + /** + * The green component of the color (0-1). + */ + @Stable + val green: Float + get() = hsvToRgbComponent(3, hue, saturation, value) + + /** + * The blue component of the color (0-1). + */ + @Stable + val blue: Float + get() = hsvToRgbComponent(1, hue, saturation, value) + + /** + * Creates an HsvColor from the given hue, saturation, and value. + */ + constructor(hue: Float, saturation: Float, value: Float) : this( + ((hue * 100).toULong() and 0xFFFFu shl 40) or + ((saturation * 1000000).toULong() and 0xFFFFFu shl 20) or + ((value * 1000000).toULong() and 0xFFFFFu) + ) + + /** + * Converts the HSV color to a Color object. + * + * **Note**: HSV to RGB conversion is a lossy process, so the resulting color may not be exactly the same as the original HSV color. + * When red, green and blue are equal, hue will be 0 and saturation will be 0. + */ + fun toColor(): Color = Color.hsv(hue, saturation, value) + + @Stable + operator fun component1(): Float = hue + + @Stable + operator fun component2(): Float = saturation + + @Stable + operator fun component3(): Float = value + + /** + * Copies the existing color, changing only the provided values. + */ + @Stable + fun copy( + hue: Float = this.hue, + saturation: Float = this.saturation, + value: Float = this.value + ): HsvColor { + return HsvColor(hue, saturation, value) + } + + /** + * Returns a string representation of the color in HSV format. + */ + @Stable + override fun toString(): String { + return "HsvColor(hue=$hue, saturation=$saturation, value=$value)" + } + + companion object { + val Saver: Saver = Saver( + save = { it.packedValue.toLong() }, + restore = { HsvColor(it.toULong()) } + ) + + private fun hsvToRgbComponent(n: Int, h: Float, s: Float, v: Float): Float { + val k = (n.toFloat() + h / 60f) % 6f + return v - (v * s * max(0f, minOf(k, 4 - k, 1f))) + } + } +} + +/** + * Creates a new [HsvColor] instance from a [Color]. + * + * @param color The Color to create an HsvColor from. + * @return A non-null instance of [HsvColor] + */ +@Stable +fun HsvColor(color: Color): HsvColor = HsvColor(color.toArgb()) + +/** + * Creates a new [Color] instance from an ARGB color int. + * + * @param color The ARGB color int to create a Color from. + * @return A non-null instance of [HsvColor] + */ +@Stable +fun HsvColor(color: Int): HsvColor = HsvColor(color.toLong()) + +/** + * Creates a new [Color] instance from an ARGB color long. + * The resulting color is in the [sRGB][ColorSpaces.Srgb] + * color space. + * + * @param color The ARGB color long to create a Color from. + * @return A non-null instance of [HsvColor] + */ +@Stable +fun HsvColor(color: Long): HsvColor { + val r = ((color shr 16) and 0xFF).toFloat() / 255f + val g = ((color shr 8) and 0xFF).toFloat() / 255f + val b = (color and 0xFF).toFloat() / 255f + + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + + val hue = when { + delta == 0f -> 0f + max == r -> ((g - b) / delta) % 6 + max == g -> ((b - r) / delta) + 2 + max == b -> ((r - g) / delta) + 4 + else -> 0f + } * 60f + + val positiveHue = if (hue < 0f) hue + 360f else hue + val saturation = if (max == 0f) 0f else 1 - min / max + + return HsvColor(positiveHue, saturation, max) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/LICENSE b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/LICENSE new file mode 100644 index 00000000000..33e326d058c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 zt64 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/README.md b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/README.md new file mode 100644 index 00000000000..a57df0ca230 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/README.md @@ -0,0 +1 @@ +Taken from https://github.com/zt64/compose-pipette/tree/main \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/RingColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/RingColorPicker.kt new file mode 100644 index 00000000000..602bd3ace0d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/RingColorPicker.kt @@ -0,0 +1,200 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* +import kotlinx.coroutines.launch +import kotlin.math.* + +/** + * A ring color picker that allows the user to select a hue by rotating a handle around the ring. The ring is + * a continuous gradient of colors from red to red. + * + * To be able to also control the saturation, use the [CircularColorPicker] composable. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param ringStrokeWidth The width of the ring + * @param thumb The composable that is used to draw the thumb + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see SquareColorPicker + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RingColorPicker( + color: () -> HsvColor, + onColorChange: (HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + ringStrokeWidth: Dp = 16.dp, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(color().hue, 1f, 1f), interactionSource) + }, + onColorChangeFinished: () -> Unit = {} +) { + RingColorPicker( + hue = { color().hue }, + onHueChange = { hue -> onColorChange(color().copy(hue = hue)) }, + modifier = modifier, + interactionSource = interactionSource, + ringStrokeWidth = ringStrokeWidth, + thumb = thumb, + onColorChangeFinished = onColorChangeFinished + ) +} + +/** + * A ring color picker that allows the user to select a hue by rotating a handle around the ring. The ring is + * a continuous gradient of colors from red to red. + * + * To be able to also control the saturation, use the [CircularColorPicker] composable. + * + * @param hue The hue of the color + * @param saturation The saturation of the color + * @param value The value of the color + * @param onHueChange Callback that is called when the hue changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param ringStrokeWidth The width of the ring + * @param thumb The composable that is used to draw the thumb + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see SquareColorPicker + */ +@Composable +fun RingColorPicker( + hue: () -> Float, + modifier: Modifier = Modifier, + saturation: () -> Float = { 1f }, + value: () -> Float = { 1f }, + onHueChange: (Float) -> Unit, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + ringStrokeWidth: Dp = 16.dp, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(Color.hsv(hue(), saturation(), value()), interactionSource) + }, + onColorChangeFinished: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + var radius by remember { mutableFloatStateOf(0f) } + var center by remember { mutableStateOf(Offset.Zero) } + val strokeWidth = with(LocalDensity.current) { ringStrokeWidth.toPx() } + + val currentOnHueChange by rememberUpdatedState(onHueChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { + radius = (it.width - strokeWidth) / 2f + center = Offset(it.width / 2f, it.height / 2f) + } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + val downPosition = down.position + val center = size.center.toOffset() + + val distanceSquared = (downPosition - center).getDistanceSquared() + val halfStroke = strokeWidth / 2f + val innerRadiusSquared = (radius - halfStroke) * (radius - halfStroke) + val outerRadiusSquared = (radius + halfStroke) * (radius + halfStroke) + + if (distanceSquared !in innerRadiusSquared..outerRadiusSquared) return@awaitEachGesture + + // Handle initial tap + currentOnHueChange(hueForPosition(downPosition, center)) + + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + currentOnHueChange(hueForPosition(change.position, center)) + } + + while (change != null && change.pressed) { + change.consume() + currentOnHueChange(hueForPosition(change.position, center)) + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .drawWithCache { + val brush = Brush.sweepGradient( + List(7) { + Color.hsv( + hue = (it * 60).toFloat(), + saturation = saturation(), + value = value() + ) + } + ) + + onDrawBehind { + drawCircle( + brush = brush, + radius = size.minDimension / 2 - strokeWidth / 2f, + style = Stroke(strokeWidth) + ) + } + } + .progressSemantics( + value = hue(), + valueRange = 0f..360f, + steps = 360 + ) + ) { + Box( + modifier = Modifier.offset { + val rad = hue() * DEG_TO_RAD + val x = center.x + radius * cos(rad) + val y = center.y + radius * sin(rad) + + IntOffset(x.roundToInt(), y.roundToInt()) + } + ) { + thumb() + } + } +} + +private fun hueForPosition(position: Offset, center: Offset): Float { + val (dx, dy) = position - center + val theta = atan2(dy, dx) + var angle = theta * RAD_TO_DEG + + if (angle < 0) angle += 360f + + return angle +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/SquareColorPicker.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/SquareColorPicker.kt new file mode 100644 index 00000000000..f409d420114 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/colorpicker/SquareColorPicker.kt @@ -0,0 +1,141 @@ +package com.lagradost.cloudstream4.compose.colorpicker + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitDragOrCancellation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.* +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * The standard square color picker that allows the user to select a color by dragging a thumb around the color space. + * + * The color is represented in HSV color space with a fixed hue. The saturation and value can be controlled by + * dragging the thumb. + * + * @param color The current color + * @param onColorChange Callback that is called when the color changes + * @param modifier The modifier to be applied to the color picker + * @param interactionSource The interaction source for the color picker + * @param thumb Composable that is used to draw the thumb + * @param shape The shape of the color picker, note that the corner radius should be kept small, + * to prevent the thumb from visually appearing outside the color picker + * @param onColorChangeFinished Callback that is called when the user finishes changing the color + * + * @see CircularColorPicker + * @see RingColorPicker + */ +@Composable +fun SquareColorPicker( + color: () -> HsvColor, + onColorChange: (color: HsvColor) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable () -> Unit = { + ColorPickerDefaults.Thumb(color().toColor(), interactionSource) + }, + shape: Shape = RectangleShape, + onColorChangeFinished: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + var size by remember { mutableStateOf(IntSize.Zero) } + + val currentColor by rememberUpdatedState(color) + val currentOnColorChange by rememberUpdatedState(onColorChange) + val currentOnColorChangeFinished by rememberUpdatedState(onColorChangeFinished) + + Box { + Canvas( + modifier = modifier + .size(ColorPickerDefaults.ComponentSize) + .onSizeChanged { size = it } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + + hsvColorForPosition(down.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + + // Start drag interaction + val interaction = DragInteraction.Start() + scope.launch { + interactionSource.emit(interaction) + } + + var change = awaitTouchSlopOrCancellation(down.id) { change, _ -> + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + } + + // Continue dragging + while (change != null && change.pressed) { + change.consume() + hsvColorForPosition(change.position, size).let { (s, v) -> + currentOnColorChange(currentColor().copy(saturation = s, value = v)) + } + change = awaitDragOrCancellation(change.id) + } + + scope.launch { + interactionSource.emit(DragInteraction.Stop(interaction)) + } + + currentOnColorChangeFinished() + } + } + .clip(shape) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + val saturationBrush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black)) + val hueBrush = Brush.horizontalGradient( + listOf( + Color.Transparent, + Color.hsv(currentColor().hue, 1f, 1f) + ) + ) + + drawRect(Color.White) + drawRect(hueBrush) + drawRect(saturationBrush) + } + + Box( + modifier = Modifier.offset { + IntOffset( + x = (currentColor().saturation * size.width).roundToInt(), + y = (size.height - currentColor().value * size.height).roundToInt() + ) + } + ) { + thumb() + } + } +} + +private fun hsvColorForPosition(position: Offset, size: IntSize): Pair { + val clampedX = position.x.coerceIn(0f, size.width.toFloat()) + val clampedY = position.y.coerceIn(0f, size.height.toFloat()) + + val saturation = clampedX / size.width + val value = 1f - (clampedY / size.height) + + return Pair(saturation, value) +} \ No newline at end of file