feat: adb connect following with scrcpy auto start

This commit is contained in:
Miuzarte
2026-04-10 19:18:46 +08:00
parent e734891eb3
commit 98443040e6
11 changed files with 233 additions and 158 deletions

View File

@@ -28,6 +28,7 @@ class Preset<T : Comparable<T>>(val values: List<T>) {
preset is Number && value is Number -> { preset is Number && value is Number -> {
kotlin.math.abs(preset.toDouble() - value.toDouble()) kotlin.math.abs(preset.toDouble() - value.toDouble())
} }
else -> if (preset > value) 1.0 else -1.0 else -> if (preset > value) 1.0 else -1.0
} }
}?.index ?: 0 }?.index ?: 0

View File

@@ -38,6 +38,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
host: String? = null, host: String? = null,
port: Int? = null, port: Int? = null,
name: String? = null, name: String? = null,
startScrcpyOnConnect: Boolean? = null,
newPort: Int? = null, newPort: Int? = null,
updateNameOnlyWhenEmpty: Boolean = false, updateNameOnlyWhenEmpty: Boolean = false,
): DeviceShortcuts { ): DeviceShortcuts {
@@ -57,9 +58,15 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
} }
val finalHost = if (updateById) host ?: old.host else old.host val finalHost = if (updateById) host ?: old.host else old.host
val finalPort = if (updateById) port ?: old.port else newPort ?: old.port val finalPort = if (updateById) port ?: old.port else newPort ?: old.port
val finalStartScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect
// 若无任何变化,返回原实例 // 若无任何变化,返回原实例
if (finalName == old.name && finalHost == old.host && finalPort == old.port) if (
finalName == old.name
&& finalHost == old.host
&& finalPort == old.port
&& finalStartScrcpyOnConnect == old.startScrcpyOnConnect
)
return this return this
val newList = devices.toMutableList().apply { val newList = devices.toMutableList().apply {
@@ -67,6 +74,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
name = finalName, name = finalName,
host = finalHost, host = finalHost,
port = finalPort, port = finalPort,
startScrcpyOnConnect = finalStartScrcpyOnConnect,
) )
} }
return DeviceShortcuts( return DeviceShortcuts(
@@ -119,13 +127,14 @@ data class DeviceShortcut(
val name: String = "", val name: String = "",
val host: String, val host: String,
val port: Int = Defaults.ADB_PORT, val port: Int = Defaults.ADB_PORT,
val startScrcpyOnConnect: Boolean = false,
) { ) {
val id: String get() = "$host:$port" val id: String get() = "$host:$port"
fun marshalToString( fun marshalToString(
separator: String = DEFAULT_SEPARATOR, separator: String = DEFAULT_SEPARATOR,
): String = listOf( ): String = listOf(
name.trim(), host.trim(), port.toString() name.trim(), host.trim(), port.toString(), if (startScrcpyOnConnect) "1" else "0"
).joinToString( ).joinToString(
separator = separator separator = separator
) )
@@ -136,16 +145,18 @@ data class DeviceShortcut(
s: String, s: String,
separator: String = DEFAULT_SEPARATOR, separator: String = DEFAULT_SEPARATOR,
): DeviceShortcut? { ): DeviceShortcut? {
val parts = s.split(separator, limit = 3) val parts = s.split(separator)
return when (parts.size) { return when (parts.size) {
3 -> { 3, 4 -> {
val name = parts[0].trim() val name = parts[0].trim()
val host = parts[1].trim() val host = parts[1].trim()
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
val startScrcpyOnConnect = parts.getOrNull(3)?.trim() == "1"
if (host.isNotBlank()) DeviceShortcut( if (host.isNotBlank()) DeviceShortcut(
name = name, name = name,
host = host, host = host,
port = port, port = port,
startScrcpyOnConnect = startScrcpyOnConnect,
) )
else null else null
} }

View File

@@ -87,7 +87,10 @@ class PersistentVideoRenderer {
fun detachDisplaySurface(surface: Surface? = null, releaseSurface: Boolean = false) { fun detachDisplaySurface(surface: Surface? = null, releaseSurface: Boolean = false) {
val requestId = surface?.let { System.identityHashCode(it) } val requestId = surface?.let { System.identityHashCode(it) }
Log.i(tag, "detachDisplaySurface(): request surfaceId=$requestId releaseSurface=$releaseSurface current=${displaySurfaceId}") Log.i(
tag,
"detachDisplaySurface(): request surfaceId=$requestId releaseSurface=$releaseSurface current=${displaySurfaceId}"
)
handler.post { handler.post {
if (released) return@post if (released) return@post
if (requestId != null && requestId != displaySurfaceId) return@post if (requestId != null && requestId != displaySurfaceId) return@post
@@ -190,7 +193,10 @@ class PersistentVideoRenderer {
setOnFrameAvailableListener({ setOnFrameAvailableListener({
val n = frameAvailableCount.incrementAndGet() val n = frameAvailableCount.incrementAndGet()
if (n == 1L || n % 120L == 0L) { if (n == 1L || n % 120L == 0L) {
Log.i(tag, "onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}") Log.i(
tag,
"onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}"
)
} }
drawFrame() drawFrame()
}, handler) }, handler)
@@ -222,7 +228,10 @@ class PersistentVideoRenderer {
.onSuccess { .onSuccess {
val consumed = frameConsumedCount.incrementAndGet() val consumed = frameConsumedCount.incrementAndGet()
if (consumed == 1L || consumed % 120L == 0L) { if (consumed == 1L || consumed % 120L == 0L) {
Log.i(tag, "drawFrame(): consumed=$consumed available=${frameAvailableCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}") Log.i(
tag,
"drawFrame(): consumed=$consumed available=${frameAvailableCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}"
)
} }
} }
.onFailure { Log.w(tag, "updateTexImage failed", it) } .onFailure { Log.w(tag, "updateTexImage failed", it) }
@@ -258,7 +267,10 @@ class PersistentVideoRenderer {
EGL14.eglSwapBuffers(eglDisplay, displayEglSurface) EGL14.eglSwapBuffers(eglDisplay, displayEglSurface)
val rendered = frameRenderedCount.incrementAndGet() val rendered = frameRenderedCount.incrementAndGet()
if (rendered == 1L || rendered % 120L == 0L) { if (rendered == 1L || rendered % 120L == 0L) {
Log.i(tag, "drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}") Log.i(
tag,
"drawFrame(): rendered=$rendered consumed=${frameConsumedCount.get()} available=${frameAvailableCount.get()} viewport=${width[0]}x${height[0]}"
)
} }
} }

View File

@@ -503,6 +503,63 @@ fun DeviceTabPage(
} }
} }
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) {
if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect
// Keep-alive loop for current target.
// On failure: try to reconnect once; if failed, fully disconnect and reset UI state.
val host = currentTargetHost
val port = currentTargetPort
while (adbConnected && currentTargetHost == host && currentTargetPort == port) {
delay(ADB_KEEPALIVE_INTERVAL_MS)
val alive = runCatching { keepAliveCheck(host, port) }.getOrElse { false }
if (alive) continue
logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
try {
connectWithTimeout(host, port)
adbConnected = true
statusLine = "$host:$port"
logEvent("ADB 自动重连成功: $host:$port")
snackbar.show("ADB 自动重连成功")
} catch (e: Exception) {
disconnectAdbConnection()
statusLine = "ADB 连接断开"
logEvent("ADB 自动重连失败: $e", Log.ERROR)
snackbar.show("ADB 自动重连失败")
break
}
}
}
suspend fun startScrcpySession() {
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
val session = scrcpy.start(options)
if (options.disableScreensaver) {
setKeepScreenOn(true)
}
statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale")
val videoDetail =
if (!options.video) "off"
else if (soBundleShared.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"
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 ""}"
logEvent(
"scrcpy 已启动: device=${session.deviceName}" +
", video=$videoDetail, audio=$audioDetail" +
", control=${options.control}, turnScreenOff=${options.turnScreenOff}" +
", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
)
snackbar.show("scrcpy 已启动")
}
suspend fun handleAdbConnected(host: String, port: Int) { suspend fun handleAdbConnected(host: String, port: Int) {
currentTargetHost = host currentTargetHost = host
currentTargetPort = port currentTargetPort = port
@@ -534,48 +591,28 @@ fun DeviceTabPage(
"sdk=${info.sdkInt}" "sdk=${info.sdkInt}"
) )
snackbar.show("ADB 已连接") snackbar.show("ADB 已连接")
}
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { if (
if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect savedShortcuts.get(host, port)?.startScrcpyOnConnect == true &&
sessionInfo == null
// Keep-alive loop for current target. ) {
// On failure: try to reconnect once; if failed, fully disconnect and reset UI state. runBusy("启动 scrcpy") {
val host = currentTargetHost startScrcpySession()
val port = currentTargetPort
while (adbConnected && currentTargetHost == host && currentTargetPort == port) {
delay(ADB_KEEPALIVE_INTERVAL_MS)
val alive = runCatching { keepAliveCheck(host, port) }.getOrElse { false }
if (alive) continue
logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
try {
connectWithTimeout(host, port)
adbConnected = true
statusLine = "$host:$port"
logEvent("ADB 自动重连成功: $host:$port")
snackbar.show("ADB 自动重连成功")
} catch (e: Exception) {
disconnectAdbConnection()
statusLine = "ADB 连接断开"
logEvent("ADB 自动重连失败: $e", Log.ERROR)
snackbar.show("ADB 自动重连失败")
break
} }
} }
} }
LaunchedEffect(
val adbPairingAutoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen adbConnected,
val adbAutoReconnectPairedDevice = asBundle.adbAutoReconnectPairedDevice asBundle.adbAutoReconnectPairedDevice,
val adbMdnsLanDiscovery = asBundle.adbMdnsLanDiscovery asBundle.adbMdnsLanDiscovery
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) { ) {
if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect if (adbConnected || !asBundle.adbAutoReconnectPairedDevice) return@LaunchedEffect
// Background auto reconnect pipeline: // Background auto reconnect pipeline:
// 1) try quick list targets with reachable TCP ports // 1) try quick list targets with reachable TCP ports
// 2) fallback to mDNS discovery // 2) fallback to mDNS discovery
val quickConnectTriedOnce = mutableSetOf<String>() val quickConnectTriedOnce = mutableSetOf<String>()
while (!adbConnected && adbAutoReconnectPairedDevice) { while (!adbConnected) {
if (busy || adbConnecting || sessionInfo != null) { if (busy || adbConnecting || sessionInfo != null) {
delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS) delay(ADB_AUTO_RECONNECT_RETRY_INTERVAL_MS)
continue continue
@@ -611,7 +648,7 @@ fun DeviceTabPage(
val discovered = withContext(Dispatchers.IO) { val discovered = withContext(Dispatchers.IO) {
adbService.discoverConnectService( adbService.discoverConnectService(
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
includeLanDevices = adbMdnsLanDiscovery, includeLanDevices = asBundle.adbMdnsLanDiscovery,
) )
} }
@@ -673,12 +710,6 @@ fun DeviceTabPage(
} }
} }
val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp
val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText
val audioBitRate = soBundleShared.audioBitRate
val videoBitRate = soBundleShared.videoBitRate
// 设备 // 设备
LazyColumn( LazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -708,7 +739,9 @@ fun DeviceTabPage(
adbConnecting && activeDeviceActionId == device.id adbConnecting && activeDeviceActionId == device.id
}, },
editingDeviceId = editingDeviceId, editingDeviceId = editingDeviceId,
onClick = {}, onClick = {
snackbar.show("长按可编辑")
},
onLongClick = { device -> onLongClick = { device ->
val connected = adbConnected val connected = adbConnected
&& currentTarget?.host == device.host && currentTarget?.host == device.host
@@ -716,7 +749,9 @@ fun DeviceTabPage(
if (connected) { if (connected) {
snackbar.show("无法修改已连接的设备") snackbar.show("无法修改已连接的设备")
} else { } else {
editingDeviceId = device.id editingDeviceId =
if (editingDeviceId != device.id) device.id
else null
} }
}, },
onAction = { device -> onAction = { device ->
@@ -769,8 +804,8 @@ fun DeviceTabPage(
name = updated.name, name = updated.name,
host = updated.host, host = updated.host,
port = updated.port, port = updated.port,
startScrcpyOnConnect = updated.startScrcpyOnConnect,
) )
editingDeviceId = null
}, },
onEditorDelete = { device -> onEditorDelete = { device ->
savedShortcuts = savedShortcuts.remove(id = device.id) savedShortcuts = savedShortcuts.remove(id = device.id)
@@ -833,10 +868,10 @@ fun DeviceTabPage(
// "使用配对码配对设备" // "使用配对码配对设备"
PairingCard( PairingCard(
busy = busy, busy = busy,
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, autoDiscoverOnDialogOpen = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = { onDiscoverTarget = {
adbService.discoverPairingService( adbService.discoverPairingService(
includeLanDevices = adbMdnsLanDiscovery, includeLanDevices = asBundle.adbMdnsLanDiscovery,
) )
}, },
onPair = { host, port, code -> onPair = { host, port, code ->
@@ -873,31 +908,7 @@ fun DeviceTabPage(
onStartStopHaptic = { haptics.contextClick() }, onStartStopHaptic = { haptics.contextClick() },
onStart = { onStart = {
runBusy("启动 scrcpy") { runBusy("启动 scrcpy") {
val options = scrcpyOptions.toClientOptions(soBundleShared).fix() startScrcpySession()
val session = scrcpy.start(options)
if (options.disableScreensaver) {
setKeepScreenOn(true)
}
statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale")
val videoDetail =
if (!options.video) "off"
else if (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", videoBitRate / 1_000_000f)}Mbps"
val audioDetail =
if (!soBundleShared.audio) "off"
else if (audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}"
else "${options.audioCodec} ${audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
logEvent(
"scrcpy 已启动: device=${session.deviceName}" +
", video=$videoDetail, audio=$audioDetail" +
", control=${options.control}, turnScreenOff=${options.turnScreenOff}" +
", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
)
snackbar.show("scrcpy 已启动")
} }
}, },
onStop = { onStop = {
@@ -938,7 +949,7 @@ fun DeviceTabPage(
PreviewCard( PreviewCard(
sessionInfo = sessionInfo, sessionInfo = sessionInfo,
nativeCore = nativeCore, nativeCore = nativeCore,
previewHeightDp = devicePreviewCardHeightDp.coerceAtLeast(120), previewHeightDp = asBundle.devicePreviewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible, controlsVisible = previewControlsVisible,
onTapped = { onTapped = {
previewControlsVisible = !previewControlsVisible previewControlsVisible = !previewControlsVisible
@@ -964,7 +975,7 @@ fun DeviceTabPage(
busy = busy, busy = busy,
outsideActions = virtualButtonLayout.first, outsideActions = virtualButtonLayout.first,
moreActions = virtualButtonLayout.second, moreActions = virtualButtonLayout.second,
showText = previewVirtualButtonShowText, showText = asBundle.previewVirtualButtonShowText,
onAction = ::sendVirtualButtonAction, onAction = ::sendVirtualButtonAction,
) )
} }

View File

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.pages package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity import android.app.Activity
import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -144,7 +145,11 @@ fun FullscreenControlScreen(
nativeCore.session?.injectKeycode(1, keycode) nativeCore.session?.injectKeycode(1, keycode)
} }
}.onFailure { e -> }.onFailure { e ->
android.util.Log.w("FullscreenControlPage", "sendKeycode failed for keycode=$keycode", e) Log.w(
"FullscreenControlPage",
"sendKeycode failed for keycode=$keycode",
e
)
} }
} }

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.pages package io.github.miuzarte.scrcpyforandroid.pages
import android.content.pm.ActivityInfo
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
class RootNavigator( class RootNavigator(

View File

@@ -229,7 +229,7 @@ data class ClientOptions(
} }
fun fix(): ClientOptions { fun fix(): ClientOptions {
when(videoSource) { when (videoSource) {
VideoSource.DISPLAY -> { VideoSource.DISPLAY -> {
cameraId = "" cameraId = ""
cameraFacing = CameraFacing.ANY cameraFacing = CameraFacing.ANY
@@ -238,6 +238,7 @@ data class ClientOptions(
cameraFps = 0u cameraFps = 0u
cameraHighSpeed = false cameraHighSpeed = false
} }
VideoSource.CAMERA -> { VideoSource.CAMERA -> {
displayId = 0 displayId = 0
maxSize = 0u maxSize = 0u

View File

@@ -3,7 +3,6 @@ package io.github.miuzarte.scrcpyforandroid.services
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale

View File

@@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore

View File

@@ -97,6 +97,8 @@ import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.overlay.OverlayDialog import top.yukonga.miuix.kmp.overlay.OverlayDialog
import top.yukonga.miuix.kmp.preference.ArrowPreference import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.preference.CheckboxLocation
import top.yukonga.miuix.kmp.preference.CheckboxPreference
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.SwitchPreference import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -435,23 +437,23 @@ internal fun ConfigPanel(
enabled = !sessionStarted && soBundle.audio, enabled = !sessionStarted && soBundle.audio,
) )
AnimatedVisibility(audioBitRateVisibility) { AnimatedVisibility(audioBitRateVisibility) {
SuperSlider( SuperSlider(
title = "音频码率", title = "音频码率",
summary = "--audio-bit-rate", summary = "--audio-bit-rate",
value = ScrcpyPresets.AudioBitRate value = ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000) .indexOfOrNearest(soBundle.audioBitRate / 1000)
.toFloat(), .toFloat(),
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt() val idx = value.roundToInt()
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) .coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
soBundle = soBundle.copy( soBundle = soBundle.copy(
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
) )
}, },
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
unit = "Kbps", unit = "Kbps",
zeroStateText = "默认", zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(), displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue = inputInitialValue =
if (soBundle.audioBitRate <= 0) "" if (soBundle.audioBitRate <= 0) ""
@@ -963,15 +965,37 @@ internal fun DeviceTile(
onEditorCancel: () -> Unit, onEditorCancel: () -> Unit,
) { ) {
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
var name by rememberSaveable(device.id) { mutableStateOf(device.name) }
var host by rememberSaveable(device.id) { mutableStateOf(device.host) }
var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) }
LaunchedEffect(device) { var draft by remember(editing, device.id) {
name = device.name mutableStateOf(if (editing) device else null)
host = device.host
port = device.port.toString()
} }
var originalDraft by remember(editing, device.id) {
mutableStateOf(if (editing) device else null)
}
var draftPortText by remember(editing, device.id) {
mutableStateOf(if (editing) device.port.toString() else null)
}
LaunchedEffect(editing, draft) {
val currentDraft = draft ?: return@LaunchedEffect
if (!editing) return@LaunchedEffect
delay(Settings.BUNDLE_SAVE_DELAY)
val trimmedHost = currentDraft.host.trim()
if (trimmedHost.isBlank()) return@LaunchedEffect
val updated = DeviceShortcut(
name = currentDraft.name.trim(),
host = trimmedHost,
port = currentDraft.port,
startScrcpyOnConnect = currentDraft.startScrcpyOnConnect,
)
if (updated != device) {
onEditorSave(updated)
}
}
val currentDraft = draft ?: device
val currentOriginalDraft = originalDraft ?: device
val currentDraftPortText = draftPortText ?: device.port.toString()
Card( Card(
colors = CardDefaults.defaultColors( colors = CardDefaults.defaultColors(
@@ -1041,48 +1065,73 @@ internal fun DeviceTile(
text = if (!isConnected) "连接" else "断开", text = if (!isConnected) "连接" else "断开",
onClick = onAction, onClick = onAction,
enabled = actionEnabled && !actionInProgress, enabled = actionEnabled && !actionInProgress,
colors = if (!isConnected && device.startScrcpyOnConnect) {
ButtonDefaults.textButtonColorsPrimary()
} else {
ButtonDefaults.textButtonColors()
},
) )
} }
} }
AnimatedVisibility(editing) { AnimatedVisibility(editing) {
Column( Column(
modifier = Modifier modifier = Modifier.padding(vertical = UiSpacing.Large),
.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
.padding(horizontal = UiSpacing.ContentVertical)
.padding(bottom = UiSpacing.ContentVertical),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)
) { ) {
SuperTextField( Column(
value = name, modifier = Modifier.padding(horizontal = UiSpacing.Large),
onValueChange = { name = it }, verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
label = "设备名称", ) {
singleLine = true, SuperTextField(
modifier = Modifier.fillMaxWidth(), value = currentDraft.name,
) onValueChange = { draft = currentDraft.copy(name = it) },
SuperTextField( label = "设备名称",
value = host, singleLine = true,
onValueChange = { host = it }, modifier = Modifier.fillMaxWidth(),
label = "IP 地址", )
singleLine = true, SuperTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), value = currentDraft.host,
modifier = Modifier.fillMaxWidth(), onValueChange = { draft = currentDraft.copy(host = it) },
) label = "IP 地址",
SuperTextField( singleLine = true,
value = port, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
onValueChange = { port = it.filter(Char::isDigit) }, modifier = Modifier.fillMaxWidth(),
label = "端口", )
singleLine = true, SuperTextField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), value = currentDraftPortText,
modifier = Modifier.fillMaxWidth(), onValueChange = {
) draftPortText = it.filter(Char::isDigit)
draft = currentDraft.copy(
port = draftPortText?.toIntOrNull() ?: Defaults.ADB_PORT
)
},
label = "端口",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
CheckboxPreference(
title = "连接后立即启动 scrcpy",
checkboxLocation = CheckboxLocation.End,
checked = currentDraft.startScrcpyOnConnect,
onCheckedChange = {
draft = currentDraft.copy(startScrcpyOnConnect = it)
},
)
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.Large),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) { ) {
TextButton( TextButton(
text = "取消", text = "取消",
onClick = onEditorCancel, onClick = {
onEditorSave(currentOriginalDraft)
onEditorCancel()
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextButton( TextButton(
@@ -1091,19 +1140,8 @@ internal fun DeviceTile(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
TextButton( TextButton(
text = "保存", text = "完成",
onClick = { onClick = onEditorCancel,
val trimmedHost = host.trim()
if (trimmedHost.isNotBlank()) {
onEditorSave(
DeviceShortcut(
name = name.trim(),
host = trimmedHost,
port = port.toIntOrNull() ?: Defaults.ADB_PORT,
),
)
}
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(), colors = ButtonDefaults.textButtonColorsPrimary(),
) )

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid.widgets package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement