diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 137733c..0d10a5d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -1,6 +1,7 @@ package io.github.miuzarte.scrcpyforandroid.models import io.github.miuzarte.scrcpyforandroid.constants.Defaults +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions // Composable 用, 不可变 List class DeviceShortcuts(val devices: List) : List by devices { @@ -40,6 +41,7 @@ class DeviceShortcuts(val devices: List) : List name: String? = null, startScrcpyOnConnect: Boolean? = null, openFullscreenOnStart: Boolean? = null, + scrcpyProfileId: String? = null, newPort: Int? = null, updateNameOnlyWhenEmpty: Boolean = false, ): DeviceShortcuts { @@ -51,38 +53,28 @@ class DeviceShortcuts(val devices: List) : List val old = devices[idx] val updateById = id != null - // 确定最终的属性值 - val finalName = when { - name == null -> old.name - updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name - else -> name - } - val finalHost = if (updateById) host ?: old.host else old.host - val finalPort = if (updateById) port ?: old.port else newPort ?: old.port - val finalStartScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect - val finalOpenFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart + val updated = DeviceShortcut( + name = when { + name == null -> old.name + updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name + else -> name + }, + host = if (updateById) host ?: old.host else old.host, + port = if (updateById) port ?: old.port else newPort ?: old.port, + startScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect, + openFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart, + scrcpyProfileId = scrcpyProfileId ?: old.scrcpyProfileId, + ) // 若无任何变化,返回原实例 - if ( - finalName == old.name - && finalHost == old.host - && finalPort == old.port - && finalStartScrcpyOnConnect == old.startScrcpyOnConnect - && finalOpenFullscreenOnStart == old.openFullscreenOnStart - ) - return this + if (updated == old) return this - val newList = devices.toMutableList().apply { - this[idx] = DeviceShortcut( - name = finalName, - host = finalHost, - port = finalPort, - startScrcpyOnConnect = finalStartScrcpyOnConnect, - openFullscreenOnStart = finalOpenFullscreenOnStart, - ) - } + val newList = devices.toMutableList() + .apply { + this[idx] = updated + } return DeviceShortcuts( - if ((updateById && (finalHost != old.host || finalPort != old.port)) + if ((updateById && (updated.host != old.host || updated.port != old.port)) || (newPort != null && newPort != old.port) ) newList.distinctBy { it.id } @@ -133,6 +125,7 @@ data class DeviceShortcut( val port: Int = Defaults.ADB_PORT, val startScrcpyOnConnect: Boolean = false, val openFullscreenOnStart: Boolean = false, + val scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID, ) { val id: String get() = "$host:$port" @@ -144,6 +137,7 @@ data class DeviceShortcut( port.toString(), if (startScrcpyOnConnect) "1" else "0", if (openFullscreenOnStart) "1" else "0", + scrcpyProfileId.trim(), ).joinToString( separator = separator ) @@ -156,19 +150,28 @@ data class DeviceShortcut( ): DeviceShortcut? { val parts = s.split(separator) return when (parts.size) { - 3, 4, 5 -> { + 3, 4, 5, 6 -> { val name = parts[0].trim() val host = parts[1].trim() val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT - val startScrcpyOnConnect = parts.getOrNull(3)?.trim() == "1" - val openFullscreenOnStart = - startScrcpyOnConnect && parts.getOrNull(4)?.trim() == "1" + + val startScrcpyOnConnect = parts.getOrNull(3) + ?.trim() == "1" + val openFullscreenOnStart = startScrcpyOnConnect + && parts.getOrNull(4) + ?.trim() == "1" + val scrcpyProfileId = parts.getOrNull(5) + ?.trim() + .takeUnless { it.isNullOrBlank() } + ?: ScrcpyOptions.GLOBAL_PROFILE_ID + if (host.isNotBlank()) DeviceShortcut( name = name, host = host, port = port, startScrcpyOnConnect = startScrcpyOnConnect, openFullscreenOnStart = openFullscreenOnStart, + scrcpyProfileId = scrcpyProfileId, ) else null } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index a74ac7a..cabe3d4 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -37,15 +37,18 @@ import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard @@ -213,6 +216,7 @@ fun DeviceTabPage( // read only val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val scrcpyProfilesState by scrcpyProfiles.state.collectAsState() DisposableEffect(Unit) { onDispose { @@ -234,6 +238,9 @@ fun DeviceTabPage( var editingDeviceId by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } var adbConnecting by rememberSaveable { mutableStateOf(false) } + var connectedScrcpyProfileId by rememberSaveable { + mutableStateOf(ScrcpyOptions.GLOBAL_PROFILE_ID) + } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } @@ -250,6 +257,14 @@ fun DeviceTabPage( ConnectionTarget(currentTargetHost, currentTargetPort) else null + fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle { + if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { + return soBundleShared + } + return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle + ?: soBundleShared + } + val sessionReconnectBlacklistHosts = remember { mutableSetOf() } val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) { @@ -307,6 +322,8 @@ fun DeviceTabPage( adbConnected = false currentTargetHost = "" currentTargetPort = Defaults.ADB_PORT + connectedScrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID + AppRuntime.currentConnectionTarget = null audioForwardingSupported = true cameraMirroringSupported = true statusLine = "未连接" @@ -334,11 +351,19 @@ fun DeviceTabPage( } fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { + val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId) val audioSupported = sdkInt !in 0..<30 audioForwardingSupported = audioSupported - if (!audioSupported && soBundleShared.audio) { + if (!audioSupported && currentBundle.audio) { scope.launch { - scrcpyOptions.updateBundle { it.copy(audio = false) } + if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { + scrcpyOptions.updateBundle { it.copy(audio = false) } + } else { + scrcpyProfiles.updateBundle( + connectedScrcpyProfileId, + currentBundle.copy(audio = false), + ) + } } logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", @@ -347,9 +372,16 @@ fun DeviceTabPage( } val cameraSupported = sdkInt !in 0..<31 cameraMirroringSupported = cameraSupported - if (!cameraSupported && soBundleShared.videoSource == "camera") { + if (!cameraSupported && currentBundle.videoSource == "camera") { scope.launch { - scrcpyOptions.updateBundle { it.copy(videoSource = "display") } + if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { + scrcpyOptions.updateBundle { it.copy(videoSource = "display") } + } else { + scrcpyProfiles.updateBundle( + connectedScrcpyProfileId, + currentBundle.copy(videoSource = "display"), + ) + } } logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", @@ -576,7 +608,8 @@ fun DeviceTabPage( } suspend fun startScrcpySession(openFullscreen: Boolean = false) { - val options = scrcpyOptions.toClientOptions(soBundleShared).fix() + val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId) + val options = scrcpyOptions.toClientOptions(activeBundle).fix() val session = scrcpy.start(options) pendingScrollToPreview = true val startAppRequest = runCatching { @@ -630,14 +663,14 @@ fun DeviceTabPage( @SuppressLint("DefaultLocale") val videoDetail = if (!options.video) "off" - else if (soBundleShared.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" + else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " + - "@${String.format("%.1f", soBundleShared.videoBitRate / 1_000_000f)}Mbps" + "@${String.format("%.1f", activeBundle.videoBitRate / 1_000_000f)}Mbps" val audioDetail = - if (!soBundleShared.audio) "off" - else if (soBundleShared.audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}" - else "${options.audioCodec} ${soBundleShared.audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}" + if (!activeBundle.audio) "off" + else if (activeBundle.audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}" + else "${options.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}" logEvent( "scrcpy 已启动: device=${session.deviceName}" + @@ -649,7 +682,8 @@ fun DeviceTabPage( } suspend fun stopScrcpySession() { - val options = scrcpyOptions.toClientOptions(soBundleShared).fix() + val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId) + val options = scrcpyOptions.toClientOptions(activeBundle).fix() scrcpy.stop() if (options.killAdbOnClose) { // TODO @@ -667,9 +701,12 @@ fun DeviceTabPage( host: String, port: Int, autoStartScrcpy: Boolean = false, autoEnterFullScreen: Boolean = false, + scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID, ) { currentTargetHost = host currentTargetPort = port + connectedScrcpyProfileId = scrcpyProfileId + AppRuntime.currentConnectionTarget = ConnectionTarget(host, port) val info = fetchConnectedDeviceInfo(NativeAdbService, host, port) val fullLabel = if (info.serial.isNotBlank()) { @@ -739,7 +776,11 @@ fun DeviceTabPage( savedShortcuts = savedShortcuts.update( host = target.host, port = target.port, ) - handleAdbConnected(target.host, target.port) + handleAdbConnected( + target.host, + target.port, + scrcpyProfileId = target.scrcpyProfileId, + ) logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") } catch (_: Exception) { } @@ -796,7 +837,11 @@ fun DeviceTabPage( savedShortcuts = savedShortcuts.update( host = discoveredHost, port = discoveredPort, ) - handleAdbConnected(discoveredHost, discoveredPort) + handleAdbConnected( + discoveredHost, + discoveredPort, + scrcpyProfileId = knownDevice.scrcpyProfileId, + ) logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") } catch (_: Exception) { } @@ -888,6 +933,7 @@ fun DeviceTabPage( autoStartScrcpy = device.startScrcpyOnConnect, autoEnterFullScreen = device.startScrcpyOnConnect && device.openFullscreenOnStart, + scrcpyProfileId = device.scrcpyProfileId, ) } catch (e: Exception) { statusLine = "ADB 连接失败" @@ -919,6 +965,7 @@ fun DeviceTabPage( port = updated.port, startScrcpyOnConnect = updated.startScrcpyOnConnect, openFullscreenOnStart = updated.openFullscreenOnStart, + scrcpyProfileId = updated.scrcpyProfileId, ) }, onEditorDelete = { device -> @@ -966,7 +1013,11 @@ fun DeviceTabPage( savedShortcuts = savedShortcuts.update( host = target.host, port = target.port, ) - handleAdbConnected(target.host, target.port) + handleAdbConnected( + target.host, + target.port, + scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID, + ) } catch (e: Exception) { statusLine = "ADB 连接失败" logEvent("ADB 连接失败: $e", Log.ERROR) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index be81c34..6c67e4f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -51,7 +51,6 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface -import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index b1dcfe5..c3cc2f9 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -10,13 +10,19 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -33,8 +39,10 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn @@ -48,9 +56,14 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource +import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles +import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -58,18 +71,27 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.DropdownImpl import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.ListPopupColumn +import top.yukonga.miuix.kmp.basic.ListPopupDefaults +import top.yukonga.miuix.kmp.basic.PopupPositionProvider import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.SnackbarHost import top.yukonga.miuix.kmp.basic.SpinnerEntry +import top.yukonga.miuix.kmp.basic.TabRowWithContour import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.extended.Store import top.yukonga.miuix.kmp.icon.extended.Tune +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet +import top.yukonga.miuix.kmp.overlay.OverlayDialog +import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.preference.ArrowPreference import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference @@ -81,8 +103,85 @@ internal fun ScrcpyAllOptionsScreen( scrollBehavior: ScrollBehavior, scrcpy: Scrcpy, ) { - val snackbar = LocalSnackbarController.current val navigator = LocalRootNavigator.current + val scope = rememberCoroutineScope() + var showProfileMenu by rememberSaveable { mutableStateOf(false) } + var showManageProfilesSheet by rememberSaveable { mutableStateOf(false) } + val qdBundleShared by quickDevices.bundleState.collectAsState() + val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val scrcpyProfilesState by scrcpyProfiles.state.collectAsState() + val initialSelectedProfileId = remember(qdBundleShared.quickDevicesList) { + val currentTarget = AppRuntime.currentConnectionTarget + if (currentTarget == null) { + ScrcpyOptions.GLOBAL_PROFILE_ID + } else { + DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList) + .get(currentTarget.host, currentTarget.port) + ?.scrcpyProfileId + ?: ScrcpyOptions.GLOBAL_PROFILE_ID + } + } + val selectedProfileIdState = rememberSaveable(initialSelectedProfileId) { + mutableStateOf(initialSelectedProfileId) + } + var selectedProfileId by selectedProfileIdState + val soBundleState = rememberSaveable(selectedProfileId, soBundleShared, scrcpyProfilesState) { + mutableStateOf( + if (selectedProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { + soBundleShared + } else { + scrcpyProfilesState.profiles + .firstOrNull { it.id == selectedProfileId } + ?.bundle ?: soBundleShared + } + ) + } + val lastValidSoBundleState = rememberSaveable(selectedProfileId) { + mutableStateOf(soBundleState.value) + } + val currentConnectedDeviceName = remember(qdBundleShared.quickDevicesList) { + val currentTarget = AppRuntime.currentConnectionTarget + if (currentTarget == null) { + null + } else { + DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList) + .get(currentTarget.host, currentTarget.port) + ?.name + ?.ifBlank { currentTarget.host } + ?: currentTarget.host + } + } + var activeProfileDialog by rememberSaveable { mutableStateOf(null) } + var profileDialogTargetId by rememberSaveable { mutableStateOf(null) } + var profileDialogInput by rememberSaveable { mutableStateOf("新配置") } + var deletingProfileId by rememberSaveable { mutableStateOf(null) } + + suspend fun saveBundleForProfile(profileId: String, bundle: ScrcpyOptions.Bundle) { + if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) { + scrcpyOptions.saveBundle(bundle) + } else { + scrcpyProfiles.updateBundle(profileId, bundle) + } + } + + suspend fun rebindDeletedProfileReferences(profileId: String) { + val shortcuts = DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList) + val updated = shortcuts.copy( + devices = shortcuts.map { device -> + if (device.scrcpyProfileId == profileId) { + device.copy(scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID) + } else { + device + } + } + ) + if (updated != shortcuts) { + quickDevices.updateBundle { bundle -> + bundle.copy(quickDevicesList = updated.marshalToString()) + } + } + } + Scaffold( topBar = { TopAppBar( @@ -95,15 +194,129 @@ internal fun ScrcpyAllOptionsScreen( ) } }, + actions = { + IconButton( + onClick = { showProfileMenu = true }, + holdDownState = showProfileMenu, + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = "配置管理", + ) + } + ProfileMenuPopup( + show = showProfileMenu, + onDismissRequest = { showProfileMenu = false }, + onManageProfiles = { + showManageProfilesSheet = true + showProfileMenu = false + }, + ) + }, scrollBehavior = scrollBehavior, ) }, - snackbarHost = { SnackbarHost(snackbar.hostState) }, + snackbarHost = { + val snackbar = LocalSnackbarController.current + SnackbarHost(snackbar.hostState) + }, ) { contentPadding -> ScrcpyAllOptionsPage( contentPadding = contentPadding, scrollBehavior = scrollBehavior, scrcpy = scrcpy, + qdBundleShared = qdBundleShared, + soBundleShared = soBundleShared, + scrcpyProfilesState = scrcpyProfilesState, + selectedProfileIdState = selectedProfileIdState, + soBundleState = soBundleState, + lastValidSoBundleState = lastValidSoBundleState, + currentConnectedDeviceName = currentConnectedDeviceName, + onSaveBundleForProfile = ::saveBundleForProfile, + ) + + + ProfileNameDialog( + mode = activeProfileDialog, + initialInput = profileDialogInput, + onDismissRequest = { + activeProfileDialog = null + profileDialogTargetId = null + }, + onConfirm = { input -> + scope.launch { + when (activeProfileDialog) { + ProfileDialogMode.Create -> { + saveBundleForProfile(selectedProfileId, soBundleState.value) + val created = scrcpyProfiles.createProfile( + requestedName = input, + bundle = soBundleState.value, + ) + selectedProfileId = created.id + } + + ProfileDialogMode.Rename -> { + val profileId = profileDialogTargetId ?: return@launch + scrcpyProfiles.renameProfile( + id = profileId, + requestedName = input, + ) + } + + null -> Unit + } + profileDialogTargetId = null + activeProfileDialog = null + } + }, + ) + + ManageProfilesSheet( + show = showManageProfilesSheet, + profiles = scrcpyProfilesState.profiles, + selectedProfileId = selectedProfileId, + onDismissRequest = { showManageProfilesSheet = false }, + onCreateProfile = { + profileDialogTargetId = null + profileDialogInput = "" + activeProfileDialog = ProfileDialogMode.Create + }, + onRenameProfile = { profileId -> + profileDialogTargetId = profileId + profileDialogInput = scrcpyProfilesState.profiles + .firstOrNull { it.id == profileId } + ?.name.orEmpty() + activeProfileDialog = ProfileDialogMode.Rename + }, + onDeleteProfile = { profileId -> + deletingProfileId = profileId + }, + onMoveProfile = { fromIndex, toIndex -> + scope.launch { + scrcpyProfiles.moveProfile(fromIndex, toIndex) + } + }, + ) + + DeleteProfileDialog( + show = deletingProfileId != null, + profileName = scrcpyProfilesState.profiles + .firstOrNull { it.id == deletingProfileId } + ?.name.orEmpty(), + onDismissRequest = { deletingProfileId = null }, + onConfirm = { + scope.launch { + val profileId = deletingProfileId ?: return@launch + val deleted = scrcpyProfiles.deleteProfile(profileId) + if (deleted) { + rebindDeletedProfileReferences(profileId) + if (selectedProfileId == profileId) { + selectedProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID + } + } + deletingProfileId = null + } + }, ) } } @@ -113,6 +326,14 @@ internal fun ScrcpyAllOptionsPage( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, scrcpy: Scrcpy, + qdBundleShared: io.github.miuzarte.scrcpyforandroid.storage.QuickDevices.Bundle, + soBundleShared: ScrcpyOptions.Bundle, + scrcpyProfilesState: io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles.State, + selectedProfileIdState: MutableState, + soBundleState: MutableState, + lastValidSoBundleState: MutableState, + currentConnectedDeviceName: String?, + onSaveBundleForProfile: suspend (String, ScrcpyOptions.Bundle) -> Unit, ) { val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() @@ -122,28 +343,60 @@ internal fun ScrcpyAllOptionsPage( var refreshBusy by rememberSaveable { mutableStateOf(false) } var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) } - val soBundleShared by scrcpyOptions.bundleState.collectAsState() - val soBundleSharedLatest by rememberUpdatedState(soBundleShared) - var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } - // 验证配置项合法性用于回滚更改 - var lastValidSoBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + var selectedProfileId by selectedProfileIdState + val selectedProfileIdLatest by rememberUpdatedState(selectedProfileId) + var soBundle by soBundleState + var lastValidSoBundle by lastValidSoBundleState val soBundleLatest by rememberUpdatedState(soBundle) - LaunchedEffect(soBundleShared) { - if (soBundle != soBundleShared) { - soBundle = soBundleShared + val profileTabs = remember(scrcpyProfilesState.profiles) { + scrcpyProfilesState.profiles.map { it.name } + } + val profileIds = remember(scrcpyProfilesState.profiles) { + scrcpyProfilesState.profiles.map { it.id } + } + val selectedProfileIndex = remember(selectedProfileId, profileIds) { + profileIds.indexOf(selectedProfileId).coerceAtLeast(0) + } + fun resolveProfileBundle(profileId: String): ScrcpyOptions.Bundle { + if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) return soBundleShared + return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle + ?: soBundleShared + } + + suspend fun bindCurrentConnectedDevice(profileId: String) { + val target = AppRuntime.currentConnectionTarget ?: return + val shortcuts = DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList) + val updated = shortcuts.update( + host = target.host, + port = target.port, + scrcpyProfileId = profileId, + ) + if (updated != shortcuts) { + quickDevices.updateBundle { bundle -> + bundle.copy(quickDevicesList = updated.marshalToString()) + } } - lastValidSoBundle = soBundleShared + } + + LaunchedEffect(selectedProfileId, soBundleShared, scrcpyProfilesState) { + val bundle = resolveProfileBundle(selectedProfileId) + if (soBundle != bundle) { + soBundle = bundle + } + lastValidSoBundle = bundle } LaunchedEffect(soBundle) { delay(Settings.BUNDLE_SAVE_DELAY) - if (soBundle != soBundleSharedLatest) { - scrcpyOptions.saveBundle(soBundle) + val currentProfileId = selectedProfileIdLatest + val savedBundle = resolveProfileBundle(currentProfileId) + if (soBundle != savedBundle) { + onSaveBundleForProfile(currentProfileId, soBundle) } } DisposableEffect(Unit) { onDispose { taskScope.launch { - scrcpyOptions.saveBundle(soBundleLatest) + onSaveBundleForProfile(selectedProfileIdLatest, soBundleLatest) } } } @@ -462,6 +715,32 @@ internal fun ScrcpyAllOptionsPage( contentPadding = contentPadding, scrollBehavior = scrollBehavior, ) { + item { + TabRowWithContour( + tabs = profileTabs, + selectedTabIndex = selectedProfileIndex, + onTabSelected = { index -> + val nextProfileId = profileIds.getOrNull(index) ?: return@TabRowWithContour + if (nextProfileId == selectedProfileId) return@TabRowWithContour + scope.launch { + onSaveBundleForProfile(selectedProfileId, soBundle) + bindCurrentConnectedDevice(nextProfileId) + selectedProfileId = nextProfileId + val profileName = profileTabs.getOrElse(index) { "全局" } + currentConnectedDeviceName?.let { deviceName -> + snackbar.show("$deviceName 已切换到配置 $profileName") + } + } + }, + // TODO + // listState = contourTabListState, + modifier = Modifier.padding(bottom = UiSpacing.ContentVertical), + minWidth = 96.dp, + maxWidth = 128.dp, + height = 64.dp, + ) + } + item { Card { TextField( @@ -1585,3 +1864,217 @@ internal fun ScrcpyAllOptionsPage( item { Spacer(Modifier.height(UiSpacing.PageBottom)) } } } + +private enum class ProfileDialogMode { + Create, + Rename, +} + +@Composable +private fun ProfileMenuPopup( + show: Boolean, + onDismissRequest: () -> Unit, + onManageProfiles: () -> Unit, +) { + OverlayListPopup( + show = show, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + alignment = PopupPositionProvider.Align.TopEnd, + onDismissRequest = onDismissRequest, + enableWindowDim = false, + ) { + ListPopupColumn { + ProfileMenuPopupItem( + text = "管理配置", + optionSize = 1, + index = 0, + onSelectedIndexChange = { onManageProfiles() }, + ) + } + } +} + +@Composable +private fun ProfileMenuPopupItem( + text: String, + optionSize: Int, + index: Int, + enabled: Boolean = true, + onSelectedIndexChange: (Int) -> Unit, +) { + if (enabled) { + DropdownImpl( + text = text, + optionSize = optionSize, + isSelected = false, + index = index, + onSelectedIndexChange = onSelectedIndexChange, + ) + return + } + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.PopupHorizontal) + .padding( + top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem, + bottom = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem, + ), + color = top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme.disabledOnSecondaryVariant, + fontWeight = FontWeight.Medium, + ) +} + +@Composable +private fun ProfileNameDialog( + mode: ProfileDialogMode?, + initialInput: String, + onDismissRequest: () -> Unit, + onConfirm: (String) -> Unit, +) { + if (mode == null) return + val focusManager = LocalFocusManager.current + var input by rememberSaveable(mode, initialInput) { mutableStateOf(initialInput) } + OverlayDialog( + show = true, + title = when (mode) { + ProfileDialogMode.Create -> "新建配置" + ProfileDialogMode.Rename -> "重命名配置" + }, + summary = "名称重复时会自动追加序号", + onDismissRequest = onDismissRequest, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextField( + value = input, + onValueChange = { input = it }, + label = "配置名称", + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.fillMaxWidth(), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + top.yukonga.miuix.kmp.basic.TextButton( + text = "取消", + onClick = onDismissRequest, + modifier = Modifier.weight(1f), + ) + top.yukonga.miuix.kmp.basic.TextButton( + text = "确定", + onClick = { onConfirm(input) }, + modifier = Modifier.weight(1f), + colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } +} + +@Composable +private fun ManageProfilesSheet( + show: Boolean, + profiles: List, + selectedProfileId: String, + onDismissRequest: () -> Unit, + onCreateProfile: () -> Unit, + onRenameProfile: (String) -> Unit, + onDeleteProfile: (String) -> Unit, + onMoveProfile: (fromIndex: Int, toIndex: Int) -> Unit, +) { + OverlayBottomSheet( + show = show, + title = "管理配置", + onDismissRequest = onDismissRequest, + endAction = { + IconButton( + onClick = onCreateProfile, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "新建配置", + ) + } + }, + ) { + ReorderableList( + itemsProvider = { + profiles.map { profile -> + ReorderableList.Item( + id = profile.id, + title = profile.name, + subtitle = when (profile.id) { + selectedProfileId -> "当前配置" + else -> "" + }, + onClick = + if (profile.id != ScrcpyOptions.GLOBAL_PROFILE_ID) { + { onRenameProfile(profile.id) } + } else null, + dragEnabled = profile.id != ScrcpyOptions.GLOBAL_PROFILE_ID, + endActions = buildList { + if (profile.id != ScrcpyOptions.GLOBAL_PROFILE_ID) { + add( + ReorderableList.EndAction.Icon( + icon = Icons.Rounded.Edit, + contentDescription = "重命名配置", + onClick = { onRenameProfile(profile.id) }, + ) + ) + add( + ReorderableList.EndAction.Icon( + icon = Icons.Rounded.DeleteOutline, + contentDescription = "删除配置", + onClick = { onDeleteProfile(profile.id) }, + ) + ) + } + }, + ) + } + }, + onSettle = onMoveProfile, + ).invoke() + Spacer(Modifier.height(UiSpacing.SheetBottom)) + } +} + +@Composable +private fun DeleteProfileDialog( + show: Boolean, + profileName: String, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + if (!show) return + OverlayDialog( + show = true, + title = "删除配置", + summary = "确认删除 \"$profileName\"?", + onDismissRequest = onDismissRequest, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + TextButton( + text = "取消", + onClick = onDismissRequest, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "删除", + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(), + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt index 097f5c2..28ccc13 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -127,8 +127,27 @@ internal fun VirtualButtonOrderPage( icon = action.icon, title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})", subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内", - checked = item.showOutside, - checkboxEnabled = action != VirtualButtonAction.MORE, + endActions = listOf( + ReorderableList.EndAction.Checkbox( + checked = item.showOutside, + enabled = action != VirtualButtonAction.MORE, + onClick = { + val checked = !item.showOutside + buttonItems = buttonItems.map { current -> + if (current.action.id == action.id) { + current.copy(showOutside = checked) + } else { + current + } + } + asBundle = asBundle.copy( + virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout( + buttonItems + ) + ) + }, + ) + ), ) } }, @@ -141,19 +160,6 @@ internal fun VirtualButtonOrderPage( virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) ) }, - showCheckbox = true, - onCheckboxChange = { id, checked -> - buttonItems = buttonItems.map { item -> - if (item.action.id == id) { - item.copy(showOutside = checked) - } else { - item - } - } - asBundle = asBundle.copy( - virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems) - ) - }, )() } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt index 8c89459..bf3d3a9 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppRuntime.kt @@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.services import android.content.Context import android.os.Build import androidx.annotation.RequiresApi +import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy @@ -17,4 +18,5 @@ object AppRuntime { } var scrcpy: Scrcpy? = null + var currentConnectionTarget: ConnectionTarget? = null } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index f7adae2..79f2324 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -44,7 +44,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { ) val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair( intPreferencesKey("device_preview_card_height_dp"), - 1080/3 + 1080 / 3 ) val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( booleanPreferencesKey("preview_virtual_button_show_text"), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt index 5fca1a8..a395d96 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -25,6 +25,9 @@ import kotlinx.parcelize.Parcelize class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { companion object { + const val GLOBAL_PROFILE_ID = "global" + const val GLOBAL_PROFILE_NAME = "全局" + val CROP = Pair( stringPreferencesKey("crop"), "", @@ -249,6 +252,65 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { booleanPreferencesKey("vd_system_decorations"), true, ) + + fun defaultBundle() = Bundle( + crop = CROP.defaultValue, + recordFilename = RECORD_FILENAME.defaultValue, + videoCodecOptions = VIDEO_CODEC_OPTIONS.defaultValue, + audioCodecOptions = AUDIO_CODEC_OPTIONS.defaultValue, + videoEncoder = VIDEO_ENCODER.defaultValue, + audioEncoder = AUDIO_ENCODER.defaultValue, + cameraId = CAMERA_ID.defaultValue, + cameraSize = CAMERA_SIZE.defaultValue, + cameraSizeCustom = CAMERA_SIZE_CUSTOM.defaultValue, + cameraSizeUseCustom = CAMERA_SIZE_USE_CUSTOM.defaultValue, + cameraAr = CAMERA_AR.defaultValue, + cameraFps = CAMERA_FPS.defaultValue, + logLevel = LOG_LEVEL.defaultValue, + videoCodec = VIDEO_CODEC.defaultValue, + audioCodec = AUDIO_CODEC.defaultValue, + videoSource = VIDEO_SOURCE.defaultValue, + audioSource = AUDIO_SOURCE.defaultValue, + recordFormat = RECORD_FORMAT.defaultValue, + cameraFacing = CAMERA_FACING.defaultValue, + maxSize = MAX_SIZE.defaultValue, + videoBitRate = VIDEO_BIT_RATE.defaultValue, + audioBitRate = AUDIO_BIT_RATE.defaultValue, + maxFps = MAX_FPS.defaultValue, + angle = ANGLE.defaultValue, + captureOrientation = CAPTURE_ORIENTATION.defaultValue, + captureOrientationLock = CAPTURE_ORIENTATION_LOCK.defaultValue, + displayOrientation = DISPLAY_ORIENTATION.defaultValue, + recordOrientation = RECORD_ORIENTATION.defaultValue, + displayImePolicy = DISPLAY_IME_POLICY.defaultValue, + displayId = DISPLAY_ID.defaultValue, + screenOffTimeout = SCREEN_OFF_TIMEOUT.defaultValue, + showTouches = SHOW_TOUCHES.defaultValue, + fullscreen = FULLSCREEN.defaultValue, + control = CONTROL.defaultValue, + videoPlayback = VIDEO_PLAYBACK.defaultValue, + audioPlayback = AUDIO_PLAYBACK.defaultValue, + turnScreenOff = TURN_SCREEN_OFF.defaultValue, + stayAwake = STAY_AWAKE.defaultValue, + disableScreensaver = DISABLE_SCREENSAVER.defaultValue, + powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue, + downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue, + cleanup = CLEANUP.defaultValue, + powerOn = POWER_ON.defaultValue, + video = VIDEO.defaultValue, + audio = AUDIO.defaultValue, + requireAudio = REQUIRE_AUDIO.defaultValue, + killAdbOnClose = KILL_ADB_ON_CLOSE.defaultValue, + cameraHighSpeed = CAMERA_HIGH_SPEED.defaultValue, + list = LIST.defaultValue, + audioDup = AUDIO_DUP.defaultValue, + newDisplay = NEW_DISPLAY.defaultValue, + startApp = START_APP.defaultValue, + startAppCustom = START_APP_CUSTOM.defaultValue, + startAppUseCustom = START_APP_USE_CUSTOM.defaultValue, + vdDestroyContent = VD_DESTROY_CONTENT.defaultValue, + vdSystemDecorations = VD_SYSTEM_DECORATIONS.defaultValue, + ) } val crop by setting(CROP) @@ -424,7 +486,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { val bundleState: StateFlow = createBundleState(::bundleFromPreferences) - private fun bundleFromPreferences(preferences: Preferences) = Bundle( + private fun bundleFromPreferences(preferences: Preferences) = defaultBundle().copy( crop = preferences.read(CROP), recordFilename = preferences.read(RECORD_FILENAME), videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt new file mode 100644 index 0000000..32f1abe --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyProfiles.kt @@ -0,0 +1,277 @@ +package io.github.miuzarte.scrcpyforandroid.storage + +import android.content.Context +import android.os.Build +import android.os.Parcel +import android.util.Base64 +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_ID +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_NAME +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") { + companion object { + private val PROFILES_JSON = Pair( + stringPreferencesKey("profiles_json"), + "", + ) + } + + data class Profile( + val id: String, + val name: String, + val bundle: ScrcpyOptions.Bundle, + val isBuiltinGlobal: Boolean = false, + ) + + data class State( + val profiles: List, + ) { + val globalProfile: Profile get() = profiles.first() + } + + private val profilesJson by setting(PROFILES_JSON) + + val state: StateFlow = createBundleState(::stateFromPreferences) + + private fun stateFromPreferences(preferences: androidx.datastore.preferences.core.Preferences): State { + val raw = preferences.read(PROFILES_JSON) + return normalizeState( + runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList()) + ) + } + + suspend fun loadState() = loadBundle(::stateFromPreferences) + + suspend fun saveState(newState: State) { + profilesJson.set(encodeState(normalizeState(newState))) + } + + suspend fun getProfiles(): List = loadState().profiles + + suspend fun getProfile(id: String): Profile? = loadState().profiles.firstOrNull { it.id == id } + + suspend fun getProfileOrGlobal(id: String?): Profile { + val state = loadState() + return state.profiles.firstOrNull { it.id == id } ?: state.globalProfile + } + + suspend fun getBundleOrGlobal(id: String?): ScrcpyOptions.Bundle = + getProfileOrGlobal(id).bundle + + suspend fun updateBundle(id: String, bundle: ScrcpyOptions.Bundle): State { + val current = loadState() + val profiles = current.profiles.map { + if (it.id == id) it.copy(bundle = bundle) + else it + } + val next = normalizeState(current.copy(profiles = profiles)) + saveState(next) + return next + } + + suspend fun createProfile( + requestedName: String, + bundle: ScrcpyOptions.Bundle = ScrcpyOptions.defaultBundle(), + ): Profile { + val current = loadState() + val profile = Profile( + id = UUID.randomUUID().toString(), + name = ensureUniqueName(current, requestedName), + bundle = bundle, + ) + val next = normalizeState(current.copy(profiles = current.profiles + profile)) + saveState(next) + return next.profiles.first { it.id == profile.id } + } + + suspend fun duplicateProfile( + sourceId: String, + requestedName: String = "", + ): Profile? { + val current = loadState() + val source = current.profiles.firstOrNull { it.id == sourceId } ?: return null + return createProfile( + requestedName = requestedName.ifBlank { source.name }, + bundle = source.bundle, + ) + } + + suspend fun renameProfile(id: String, requestedName: String): Profile? { + if (id == GLOBAL_PROFILE_ID) return null + val current = loadState() + val existing = current.profiles.firstOrNull { it.id == id } ?: return null + val updated = existing.copy( + name = ensureUniqueName(current, requestedName, excludeId = id), + ) + val next = normalizeState( + current.copy( + profiles = current.profiles.map { + if (it.id == id) updated else it + } + ) + ) + saveState(next) + return next.profiles.firstOrNull { it.id == id } + } + + suspend fun deleteProfile(id: String): Boolean { + if (id == GLOBAL_PROFILE_ID) return false + val current = loadState() + if (current.profiles.none { it.id == id }) return false + val next = normalizeState( + current.copy( + profiles = current.profiles.filterNot { it.id == id } + ) + ) + saveState(next) + return true + } + + suspend fun moveProfile(fromIndex: Int, toIndex: Int): State { + val current = loadState() + if (fromIndex !in current.profiles.indices || toIndex !in current.profiles.indices) { + return current + } + if (fromIndex == toIndex || fromIndex == 0 || toIndex == 0) { + return current + } + val mutable = current.profiles.toMutableList() + val item = mutable.removeAt(fromIndex) + val target = if (toIndex > fromIndex) toIndex - 1 else toIndex + mutable.add(target, item) + val next = normalizeState(current.copy(profiles = mutable)) + saveState(next) + return next + } + + fun ensureUniqueName( + state: State, + requestedName: String, + excludeId: String? = null, + ): String { + val baseName = requestedName.trim().ifBlank { "配置" } + if (baseName == GLOBAL_PROFILE_NAME) return ensureUniqueName(state, "配置", excludeId) + val existingNames = state.profiles + .filterNot { it.id == excludeId } + .map { it.name } + .toSet() + if (baseName !in existingNames) return baseName + var suffix = 1 + while (true) { + val candidate = "$baseName ($suffix)" + if (candidate !in existingNames) return candidate + suffix++ + } + } + + private fun normalizeState(state: State): State { + val global = state.profiles.firstOrNull { it.id == GLOBAL_PROFILE_ID } + ?.copy(name = GLOBAL_PROFILE_NAME, isBuiltinGlobal = true) + ?: Profile( + id = GLOBAL_PROFILE_ID, + name = GLOBAL_PROFILE_NAME, + bundle = ScrcpyOptions.defaultBundle(), + isBuiltinGlobal = true, + ) + val usedNames = linkedSetOf(global.name) + val others = buildList { + state.profiles + .asSequence() + .filterNot { it.id == GLOBAL_PROFILE_ID } + .forEach { profile -> + val baseName = profile.name.trim().ifBlank { "配置" } + .takeUnless { it == GLOBAL_PROFILE_NAME } + ?: "配置" + var normalizedName = baseName + if (normalizedName in usedNames) { + var suffix = 1 + while (true) { + val candidate = "$baseName ($suffix)" + if (candidate !in usedNames) { + normalizedName = candidate + break + } + suffix++ + } + } + usedNames += normalizedName + add(profile.copy(name = normalizedName, isBuiltinGlobal = false)) + } + } + return State(listOf(global) + others) + } + + private fun encodeState(state: State): String { + val array = JSONArray() + for (profile in state.profiles) { + array.put( + JSONObject() + .put("id", profile.id) + .put("name", profile.name) + .put("bundle", encodeBundle(profile.bundle)) + ) + } + return array.toString() + } + + private fun decodeState(raw: String): State { + if (raw.isBlank()) return State(emptyList()) + val array = JSONArray(raw) + val profiles = buildList { + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val id = item.optString("id").trim() + val name = item.optString("name").trim() + val bundleRaw = item.optString("bundle") + if (id.isBlank()) continue + add( + Profile( + id = id, + name = name.ifBlank { "配置" }, + bundle = decodeBundle(bundleRaw), + isBuiltinGlobal = id == GLOBAL_PROFILE_ID, + ) + ) + } + } + return State(profiles) + } + + private fun encodeBundle(bundle: ScrcpyOptions.Bundle): String { + val parcel = Parcel.obtain() + return try { + parcel.writeParcelable(bundle, 0) + Base64.encodeToString(parcel.marshall(), Base64.NO_WRAP) + } finally { + parcel.recycle() + } + } + + private fun decodeBundle(raw: String): ScrcpyOptions.Bundle { + if (raw.isBlank()) return ScrcpyOptions.defaultBundle() + val bytes = runCatching { Base64.decode(raw, Base64.DEFAULT) }.getOrNull() + ?: return ScrcpyOptions.defaultBundle() + val parcel = Parcel.obtain() + return try { + parcel.unmarshall(bytes, 0, bytes.size) + parcel.setDataPosition(0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + parcel.readParcelable( + ScrcpyOptions.Bundle::class.java.classLoader, + ScrcpyOptions.Bundle::class.java, + ) + } else { + @Suppress("DEPRECATION") + parcel.readParcelable(ScrcpyOptions.Bundle::class.java.classLoader) + } ?: ScrcpyOptions.defaultBundle() + } catch (_: Throwable) { + ScrcpyOptions.defaultBundle() + } finally { + parcel.recycle() + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt index 57ffa88..26ee065 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Storage.kt @@ -13,5 +13,6 @@ object Storage { val appSettings: AppSettings by lazy { AppSettings(appContext) } val quickDevices: QuickDevices by lazy { QuickDevices(appContext) } val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) } + val scrcpyProfiles: ScrcpyProfiles by lazy { ScrcpyProfiles(appContext) } val adbClientData: AdbClientData by lazy { AdbClientData(appContext) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 80361cf..c4abcdd 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -75,6 +75,7 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions @@ -919,6 +920,8 @@ internal fun DeviceTile( onEditorCancel: () -> Unit, ) { val haptics = rememberAppHaptics() + val snackbar = LocalSnackbarController.current + val scrcpyProfilesState by Storage.scrcpyProfiles.state.collectAsState() var draft by remember(editing, device.id) { mutableStateOf(if (editing) device else null) @@ -943,6 +946,7 @@ internal fun DeviceTile( startScrcpyOnConnect = currentDraft.startScrcpyOnConnect, openFullscreenOnStart = currentDraft.startScrcpyOnConnect && currentDraft.openFullscreenOnStart, + scrcpyProfileId = currentDraft.scrcpyProfileId, ) if (updated != device) { onEditorSave(updated) @@ -952,6 +956,15 @@ internal fun DeviceTile( val currentDraft = draft ?: device val currentOriginalDraft = originalDraft ?: device val currentDraftPortText = draftPortText ?: device.port.toString() + val profileNames = remember(scrcpyProfilesState.profiles) { + scrcpyProfilesState.profiles.map { it.name } + } + val profileIds = remember(scrcpyProfilesState.profiles) { + scrcpyProfilesState.profiles.map { it.id } + } + val profileDropdownIndex = remember(currentDraft.scrcpyProfileId, profileIds) { + profileIds.indexOf(currentDraft.scrcpyProfileId).coerceAtLeast(0) + } Card( colors = CardDefaults.defaultColors( @@ -1087,6 +1100,20 @@ internal fun DeviceTile( }, ) } + OverlayDropdownPreference( + title = "Scrcpy 配置", + items = profileNames, + selectedIndex = profileDropdownIndex, + onSelectedIndexChange = { + val profileId = profileIds.getOrElse(it) { + ScrcpyOptions.GLOBAL_PROFILE_ID + } + val profileName = profileNames.getOrElse(it) { "全局" } + val deviceName = currentDraft.name.ifBlank { currentDraft.host } + draft = currentDraft.copy(scrcpyProfileId = profileId) + snackbar.show("$deviceName 已切换到配置 $profileName") + }, + ) } Row( modifier = Modifier diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt index dad7ce2..1b58bc1 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt @@ -1,11 +1,14 @@ package io.github.miuzarte.scrcpyforandroid.widgets +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DragIndicator @@ -34,18 +37,31 @@ class ReorderableList( private val orientation: Orientation = Orientation.Column, private val onSettle: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, private val modifier: Modifier = Modifier, - private val showCheckbox: Boolean = false, - private val onCheckboxChange: ((String, Boolean) -> Unit)? = null, ) { enum class Orientation { Column, Row; } + sealed interface EndAction { + data class Icon( + val icon: ImageVector, + val contentDescription: String, + val onClick: () -> Unit, + ) : EndAction + + data class Checkbox( + val checked: Boolean, + val enabled: Boolean = true, + val onClick: () -> Unit, + ) : EndAction + } + data class Item( val id: String, val icon: ImageVector? = null, val title: String, val subtitle: String, - val checked: Boolean = true, - val checkboxEnabled: Boolean = true, + val onClick: (() -> Unit)? = null, + val dragEnabled: Boolean = true, + val endActions: List = emptyList(), ) @Composable @@ -69,6 +85,7 @@ class ReorderableList( Row( modifier = Modifier .fillMaxWidth() + .height(IntrinsicSize.Min) .padding( horizontal = UiSpacing.CardTitle, vertical = UiSpacing.FieldLabelBottom @@ -77,6 +94,16 @@ class ReorderableList( horizontalArrangement = Arrangement.SpaceBetween, ) { Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .then( + if (item.onClick != null) { + Modifier.clickable(onClick = item.onClick) + } else { + Modifier + } + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) ) { @@ -101,33 +128,34 @@ class ReorderableList( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) ) { - if (showCheckbox) Checkbox( - state = if (item.checked) ToggleableState.On else ToggleableState.Off, - onClick = { - onCheckboxChange?.invoke(item.id, !item.checked) - }, - enabled = item.checkboxEnabled - ) - IconButton( - onClick = { - haptics.contextClick() - }, - modifier = Modifier - .draggableHandle( - onDragStarted = { - haptics.longPress() - }, - onDragStopped = { - haptics.confirm() - }, - ), - ) { - Icon( - Icons.Rounded.DragIndicator, - contentDescription = "拖动排序", - tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + item.endActions.forEach { action -> + EndActionView( + action = action, + fallbackContentDescription = item.title, ) } + if (item.dragEnabled) { + IconButton( + onClick = { + haptics.contextClick() + }, + modifier = Modifier + .draggableHandle( + onDragStarted = { + haptics.longPress() + }, + onDragStopped = { + haptics.confirm() + }, + ), + ) { + Icon( + Icons.Rounded.DragIndicator, + contentDescription = "拖动排序", + tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } } } } @@ -158,35 +186,41 @@ class ReorderableList( horizontalAlignment = Alignment.CenterHorizontally, ) { Row( + modifier = Modifier.then( + if (item.onClick != null) { + Modifier.clickable(onClick = item.onClick) + } else { + Modifier + } + ), verticalAlignment = Alignment.CenterVertically, ) { - if (showCheckbox) { - Checkbox( - state = if (item.checked) ToggleableState.On else ToggleableState.Off, - onClick = { - onCheckboxChange?.invoke(item.id, !item.checked) - }, - enabled = item.checkboxEnabled + item.endActions.forEach { action -> + EndActionView( + action = action, + fallbackContentDescription = item.title, ) Spacer(Modifier.padding(horizontal = 4.dp)) } - IconButton( - onClick = {}, - modifier = Modifier - .draggableHandle( - onDragStarted = { - haptics.longPress() - }, - onDragStopped = { - haptics.confirm() - }, - ), - ) { - Icon( - Icons.Rounded.DragIndicator, - contentDescription = "拖动排序", - tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, - ) + if (item.dragEnabled) { + IconButton( + onClick = {}, + modifier = Modifier + .draggableHandle( + onDragStarted = { + haptics.longPress() + }, + onDragStopped = { + haptics.confirm() + }, + ), + ) { + Icon( + Icons.Rounded.DragIndicator, + contentDescription = "拖动排序", + tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } } } Spacer(Modifier.padding(UiSpacing.ContentVertical)) @@ -211,3 +245,29 @@ class ReorderableList( } } } + +@Composable +private fun EndActionView( + action: ReorderableList.EndAction, + fallbackContentDescription: String, +) { + when (action) { + is ReorderableList.EndAction.Checkbox -> Checkbox( + state = if (action.checked) ToggleableState.On else ToggleableState.Off, + onClick = action.onClick, + enabled = action.enabled, + ) + + is ReorderableList.EndAction.Icon -> IconButton( + onClick = action.onClick, + ) { + Icon( + action.icon, + contentDescription = action.contentDescription.ifBlank { + fallbackContentDescription + }, + tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + } +}