Compare commits
4 Commits
db6c4b9bb8
...
ca089428b8
| Author | SHA1 | Date | |
|---|---|---|---|
| ca089428b8 | |||
| 37fa52fbf6 | |||
| a336734897 | |||
| 58eefc88c6 |
@@ -1,13 +1,30 @@
|
||||
package io.github.miuzarte.scrcpyforandroid
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
|
||||
@@ -16,9 +33,13 @@ import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
|
||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
|
||||
import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbConnectionCoordinator
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
// 生物认证需要 FragmentActivity
|
||||
@@ -45,11 +66,6 @@ class MainActivity: FragmentActivity() {
|
||||
AppRuntime.init(applicationContext)
|
||||
AppScreenOn.register(window)
|
||||
|
||||
// 解析命令行启动参数(adb shell am start --es host ...)
|
||||
AdbLaunchParams.fromIntent(intent)?.let {
|
||||
AppRuntime.adbLaunchParams = it
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
PasswordRepository.refresh()
|
||||
val cached = getAppLanguageTag(applicationContext)
|
||||
@@ -69,9 +85,28 @@ class MainActivity: FragmentActivity() {
|
||||
|
||||
enableEdgeToEdge()
|
||||
|
||||
val launchParams = AdbLaunchParams.fromIntent(intent)
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
if (launchParams != null) {
|
||||
AppRuntime.adbLaunchParams = launchParams
|
||||
}
|
||||
|
||||
var showMainScreen by remember { mutableStateOf(launchParams != null) }
|
||||
|
||||
if (showMainScreen) {
|
||||
MainScreen()
|
||||
}
|
||||
|
||||
if (!showMainScreen) {
|
||||
QuickConnectDialog(
|
||||
onConnect = { host, port -> doConnect(host, port) },
|
||||
onMoreSettings = { showMainScreen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -107,11 +142,384 @@ class MainActivity: FragmentActivity() {
|
||||
return maxOf(width, height).toFloat() / minOf(width, height).toFloat()
|
||||
}
|
||||
|
||||
// ======================== 快速连接弹窗 ========================
|
||||
|
||||
@Composable
|
||||
private fun QuickConnectDialog(
|
||||
onConnect: (String, Int) -> Unit,
|
||||
onMoreSettings: () -> Unit,
|
||||
) {
|
||||
var host by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("5555") }
|
||||
var showHistory by remember { mutableStateOf(false) }
|
||||
var showPermissionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// 无线配对
|
||||
var showPairing by remember { mutableStateOf(false) }
|
||||
var pairingHost by remember { mutableStateOf("") }
|
||||
var pairingPort by remember { mutableStateOf("5555") }
|
||||
var pairingCode by remember { mutableStateOf("") }
|
||||
var pairingResult by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val pairingCoordinator = remember { DeviceAdbConnectionCoordinator() }
|
||||
|
||||
// 读取历史记录
|
||||
val historyEntries = remember { loadConnectHistory() }
|
||||
var history by remember { mutableStateOf(historyEntries) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (showHistory) {
|
||||
HistoryScreen(
|
||||
entries = history,
|
||||
onSelect = { entry ->
|
||||
host = entry.host
|
||||
port = entry.port.toString()
|
||||
showHistory = false
|
||||
},
|
||||
onDelete = { entry ->
|
||||
history = history.filter { it != entry }
|
||||
saveConnectHistory(history)
|
||||
},
|
||||
onBack = { showHistory = false },
|
||||
)
|
||||
} else if (showPairing) {
|
||||
// 无线配对界面
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("无线配对", style = MaterialTheme.typography.titleMedium)
|
||||
IconButton(onClick = { showPairing = false }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "返回")
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = pairingHost,
|
||||
onValueChange = { pairingHost = it },
|
||||
label = { Text("主机地址 (IP)") },
|
||||
placeholder = { Text("192.168.1.100") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pairingPort,
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.all { it.isDigit() } && newValue.length <= 5) {
|
||||
pairingPort = newValue
|
||||
}
|
||||
},
|
||||
label = { Text("端口") },
|
||||
placeholder = { Text("5555") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pairingCode,
|
||||
onValueChange = { pairingCode = it },
|
||||
label = { Text("配对码(6位数字)") },
|
||||
placeholder = { Text("123456") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
pairingResult?.let { result ->
|
||||
Text(
|
||||
text = result,
|
||||
color = if (result.contains("成功")) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val h = pairingHost.ifBlank { return@Button }
|
||||
val p = pairingPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return@Button
|
||||
val code = pairingCode.ifBlank { return@Button }
|
||||
pairingResult = "正在配对..."
|
||||
scope.launch {
|
||||
val success = pairingCoordinator.pair(h, p, code)
|
||||
pairingResult = if (success) "配对成功!" else "配对失败,请检查信息"
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("开始配对")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 主弹窗界面
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Scrcpy For Android",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("主机地址 (IP)") },
|
||||
placeholder = { Text("192.168.1.100") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.all { it.isDigit() } && newValue.length <= 5) {
|
||||
port = newValue
|
||||
}
|
||||
},
|
||||
label = { Text("端口") },
|
||||
placeholder = { Text("5555") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// 历史记录按钮
|
||||
if (history.isNotEmpty()) {
|
||||
TextButton(onClick = { showHistory = true }) {
|
||||
Icon(
|
||||
Icons.Default.History,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("历史记录 (${history.size})")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 连接按钮
|
||||
Button(
|
||||
onClick = {
|
||||
val hostValue = host.ifBlank { "192.168.1.100" }
|
||||
val portValue = port.toIntOrNull()?.takeIf { it in 1..65535 } ?: 5555
|
||||
host = hostValue
|
||||
port = portValue.toString()
|
||||
|
||||
val hasOverlay = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Settings.canDrawOverlays(this@MainActivity)
|
||||
} else true
|
||||
|
||||
if (!hasOverlay) {
|
||||
showPermissionDialog = true
|
||||
} else {
|
||||
onConnect(hostValue, portValue)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("连接")
|
||||
}
|
||||
|
||||
// 无线配对按钮
|
||||
OutlinedButton(
|
||||
onClick = { showPairing = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("无线配对")
|
||||
}
|
||||
|
||||
// 更多设置
|
||||
TextButton(onClick = onMoreSettings) {
|
||||
Text("更多设置 →")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 悬浮窗权限弹窗
|
||||
if (showPermissionDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showPermissionDialog = false },
|
||||
title = { Text("需要悬浮窗权限") },
|
||||
text = {
|
||||
Text("悬浮窗功能需要\"显示在其他应用上层\"权限才能正常工作。\n\n请在系统设置中授予该权限后重试。")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showPermissionDialog = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
android.net.Uri.parse("package:$packageName")
|
||||
)
|
||||
startActivity(intent)
|
||||
}
|
||||
}) {
|
||||
Text("去设置")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showPermissionDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doConnect(host: String, port: Int) {
|
||||
// 保存历史记录
|
||||
saveHostToHistory(host, port)
|
||||
|
||||
AppRuntime.adbLaunchParams = AdbLaunchParams(
|
||||
host = host,
|
||||
port = port,
|
||||
fullscreen = true,
|
||||
video = true,
|
||||
audio = false,
|
||||
)
|
||||
startActivity(
|
||||
StreamActivity.createIntent(this@MainActivity).apply {
|
||||
putExtra("host", host)
|
||||
putExtra("port", port)
|
||||
}
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
// ======================== 历史记录管理 ========================
|
||||
|
||||
private data class HostEntry(val host: String, val port: Int) {
|
||||
fun toLabel() = "$host:$port"
|
||||
}
|
||||
|
||||
private fun saveHostToHistory(host: String, port: Int) {
|
||||
val entries = loadConnectHistory().toMutableList()
|
||||
entries.removeAll { it.host == host && it.port == port }
|
||||
entries.add(0, HostEntry(host, port))
|
||||
// 最多保存 10 条
|
||||
val trimmed = entries.take(10)
|
||||
saveConnectHistory(trimmed)
|
||||
}
|
||||
|
||||
private fun loadConnectHistory(): List<HostEntry> {
|
||||
return try {
|
||||
val json = getSharedPreferences(HISTORY_PREFS, MODE_PRIVATE)
|
||||
.getString(KEY_HISTORY, null) ?: return emptyList()
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
HostEntry(obj.getString("host"), obj.getInt("port"))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveConnectHistory(entries: List<HostEntry>) {
|
||||
val arr = JSONArray()
|
||||
entries.forEach { entry ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("host", entry.host)
|
||||
put("port", entry.port)
|
||||
})
|
||||
}
|
||||
getSharedPreferences(HISTORY_PREFS, MODE_PRIVATE).edit {
|
||||
putString(KEY_HISTORY, arr.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryScreen(
|
||||
entries: List<HostEntry>,
|
||||
onSelect: (HostEntry) -> Unit,
|
||||
onDelete: (HostEntry) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "历史记录",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.Close, contentDescription = "返回")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(entries, key = { it.toLabel() }) { entry ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(entry) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = entry.toLabel(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = "点击选择此地址",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onDelete(entry) }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "删除",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
private const val PHONE_LANDSCAPE_LOCK_ASPECT_RATIO = 16f / 9f
|
||||
|
||||
private const val LOCALE_PREFS = "locale_cache"
|
||||
private const val KEY_LANGUAGE_TAG = "language_tag"
|
||||
private const val HISTORY_PREFS = "connect_history"
|
||||
private const val KEY_HISTORY = "hosts"
|
||||
|
||||
fun getAppLanguageTag(context: Context) =
|
||||
context.getSharedPreferences(LOCALE_PREFS, MODE_PRIVATE)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.pages.QuickLaunchScreen
|
||||
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
@@ -19,7 +20,7 @@ import java.lang.ref.WeakReference
|
||||
|
||||
class StreamActivity: FragmentActivity() {
|
||||
|
||||
private val floatingWindow by lazy { FloatingVideoWindow(this) }
|
||||
internal val floatingWindow by lazy { FloatingVideoWindow(this) }
|
||||
private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -27,10 +28,17 @@ class StreamActivity: FragmentActivity() {
|
||||
currentActivityRef = WeakReference(this)
|
||||
AppScreenOn.register(window)
|
||||
|
||||
// 检测是否为快速启动模式(从 MainActivity 对话框直接跳转)
|
||||
val isQuickLaunch = intent.hasExtra("host")
|
||||
|
||||
setContent {
|
||||
if (isQuickLaunch) {
|
||||
QuickLaunchScreen(activity = this)
|
||||
} else {
|
||||
StreamScreen(activity = this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.pages
|
||||
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.services.*
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TAG = "QuickLaunchScreen"
|
||||
|
||||
/**
|
||||
* 快速启动界面:连接 ADB → 启动 Scrcpy → 显示悬浮窗 → Activity 退到后台。
|
||||
*
|
||||
* 前提:MainActivity 已检查悬浮窗权限,传给此界面时权限应已授予。
|
||||
*/
|
||||
@Composable
|
||||
fun QuickLaunchScreen(activity: StreamActivity) {
|
||||
val context = LocalContext.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val host = activity.intent.getStringExtra("host") ?: "192.168.1.100"
|
||||
val port = activity.intent.getIntExtra("port", 5555)
|
||||
|
||||
var statusText by remember { mutableStateOf("正在连接 ${host}:${port}...") }
|
||||
var hasStarted by remember { mutableStateOf(false) }
|
||||
|
||||
// 读取 AppSettings
|
||||
val asBundle by Storage.appSettings.bundleState.collectAsState()
|
||||
|
||||
// 创建 Scrcpy 实例(与 MainScreen 相同方式)
|
||||
val scrcpy = remember(appContext) {
|
||||
val customServerUri = asBundle.customServerUri.ifBlank { null }
|
||||
val customServerVersion = asBundle.customServerVersion
|
||||
.ifBlank { Scrcpy.DEFAULT_SERVER_VERSION }
|
||||
val serverRemotePath = asBundle.serverRemotePath
|
||||
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
|
||||
val lowLatency = asBundle.lowLatency
|
||||
|
||||
Scrcpy(
|
||||
appContext = appContext,
|
||||
customServerUri = customServerUri,
|
||||
serverVersion = customServerVersion,
|
||||
serverRemotePath = serverRemotePath,
|
||||
lowLatency = lowLatency,
|
||||
).also {
|
||||
AppRuntime.scrcpy = it
|
||||
Log.i(TAG, "Scrcpy instance created")
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 DeviceConnectionServices
|
||||
val deviceConnectionServices = remember {
|
||||
val adbCoordinator = DeviceAdbConnectionCoordinator()
|
||||
val connectionStateStore = ConnectionStateStore()
|
||||
val connectionController = ConnectionController(
|
||||
scrcpy = scrcpy,
|
||||
stateStore = connectionStateStore,
|
||||
adbCoordinator = adbCoordinator,
|
||||
)
|
||||
val autoReconnectManager = DeviceAdbAutoReconnectManager(
|
||||
controller = connectionController,
|
||||
stateStore = connectionStateStore,
|
||||
)
|
||||
DeviceConnectionServices(
|
||||
adbCoordinator = adbCoordinator,
|
||||
connectionStateStore = connectionStateStore,
|
||||
connectionController = connectionController,
|
||||
autoReconnectManager = autoReconnectManager,
|
||||
)
|
||||
}
|
||||
|
||||
val viewModelFactory = remember(scrcpy, deviceConnectionServices) {
|
||||
DeviceTabViewModel.Factory(scrcpy, deviceConnectionServices)
|
||||
}
|
||||
val viewModel: DeviceTabViewModel = viewModel(factory = viewModelFactory)
|
||||
|
||||
// 监听 session 状态
|
||||
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
||||
|
||||
// 监听连接状态
|
||||
val connectionState by deviceConnectionServices.connectionStateStore.state.collectAsState()
|
||||
|
||||
// 启动连接流程
|
||||
LaunchedEffect(Unit) {
|
||||
if (hasStarted) return@LaunchedEffect
|
||||
hasStarted = true
|
||||
Log.i(TAG, "Starting connection to ${host}:${port}")
|
||||
|
||||
AppRuntime.adbLaunchParams = AdbLaunchParams(
|
||||
host = host,
|
||||
port = port,
|
||||
fullscreen = true,
|
||||
video = true,
|
||||
audio = false,
|
||||
)
|
||||
viewModel.processLaunchParams()
|
||||
}
|
||||
|
||||
// 监控连接状态变化
|
||||
LaunchedEffect(connectionState) {
|
||||
val state = connectionState
|
||||
if (state.lastError != null) {
|
||||
statusText = "连接失败: $state.lastError"
|
||||
} else if (!state.adbSession.isConnected && state.disconnectCause != null) {
|
||||
statusText = when (state.disconnectCause) {
|
||||
DisconnectCause.User -> "用户断开"
|
||||
DisconnectCause.ConnectFailed -> "连接失败,请检查地址和端口"
|
||||
DisconnectCause.KillAdbOnClose -> "ADB 连接已断开"
|
||||
DisconnectCause.KeepAliveFailed -> "保活连接失败"
|
||||
DisconnectCause.AutoReconnectFailed -> "自动重连失败"
|
||||
DisconnectCause.SwitchTarget -> "已切换目标"
|
||||
else -> "连接已断开: ${state.disconnectCause}"
|
||||
}
|
||||
} else if (state.adbSession.isConnected && sessionInfo == null) {
|
||||
statusText = "ADB 已连接,正在启动投屏..."
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(deviceConnectionServices) {
|
||||
onDispose {
|
||||
deviceConnectionServices.autoReconnectManager.close()
|
||||
AppScreenOn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// 当 session 建立后,显示悬浮窗并退到后台
|
||||
LaunchedEffect(sessionInfo) {
|
||||
val sess = sessionInfo ?: return@LaunchedEffect
|
||||
Log.i(TAG, "Session started: ${sess.width}x${sess.height}")
|
||||
|
||||
statusText = "连接成功,正在启动悬浮窗..."
|
||||
|
||||
// 最终检查权限(MainActivity 已预检,这里是二次保障)
|
||||
val hasOverlay = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Settings.canDrawOverlays(context)
|
||||
} else true
|
||||
|
||||
if (!hasOverlay) {
|
||||
statusText = "缺少悬浮窗权限,请从应用信息中手动授予"
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
delay(500) // 等待视频渲染启动
|
||||
|
||||
activity.floatingWindow.show(
|
||||
onClose = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
AppRuntime.scrcpy?.stop(Scrcpy.StopReason.USER)
|
||||
}
|
||||
activity.finish()
|
||||
},
|
||||
onFullscreen = {
|
||||
if (!activity.isFinishing && !activity.isDestroyed) {
|
||||
activity.startActivity(
|
||||
StreamActivity.createIntent(activity).apply {
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
activity.startActivity(StreamActivity.createIntent(activity))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 退到后台
|
||||
activity.moveTaskToBack(true)
|
||||
}
|
||||
|
||||
// 加载界面
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(32.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = statusText,
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
// 窗口位置
|
||||
private var windowX: Int = 0
|
||||
private var windowY: Int = 0
|
||||
// 窗口尺寸(持久化,hide/show 之间保持)
|
||||
private var windowWidth: Int = 0
|
||||
private var windowHeight: Int = 0
|
||||
|
||||
// 拖动相关
|
||||
private var dragStartX = 0f
|
||||
@@ -73,6 +76,19 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
private var windowStartY = 0
|
||||
private var isDragging = false
|
||||
|
||||
// 缩放相关
|
||||
private var isResizing = false
|
||||
private var resizeStartX = 0f
|
||||
private var resizeStartY = 0f
|
||||
private var resizeStartWidth = 0
|
||||
private var resizeStartHeight = 0
|
||||
// 视频宽高比(宽/高),缩放时锁定此比例
|
||||
private var videoAspectRatio: Float = 9f / 16f
|
||||
|
||||
// 最小/最大窗口尺寸
|
||||
private val minWindowWidth get() = dp(120)
|
||||
private val minWindowHeight get() = dp(150)
|
||||
|
||||
private var onCloseCallback: (() -> Unit)? = null
|
||||
private var onFullscreenCallback: (() -> Unit)? = null
|
||||
|
||||
@@ -108,6 +124,9 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
/**
|
||||
* 显示悬浮窗。
|
||||
* 在 Activity.onStop() 时调用。
|
||||
*
|
||||
* 窗口尺寸根据远程设备视频宽高比自适应计算,
|
||||
* 确保悬浮窗保持与手机屏幕相同的比例。
|
||||
*/
|
||||
fun show(onClose: () -> Unit, onFullscreen: () -> Unit) {
|
||||
if (isShowing) return
|
||||
@@ -122,24 +141,41 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
createWindowView()
|
||||
setupTouchHandler()
|
||||
|
||||
// 计算初始尺寸(首次显示时,根据视频比例计算;之后使用已调整的尺寸)
|
||||
if (windowWidth == 0 || windowHeight == 0) {
|
||||
val screenWidth = metrics.widthPixels
|
||||
val screenHeight = metrics.heightPixels
|
||||
|
||||
// 从 session 获取远程设备视频尺寸,计算宽高比
|
||||
val session = AppRuntime.scrcpy?.currentSessionState?.value
|
||||
val videoAspect = if (session != null) {
|
||||
session.width.toFloat() / session.height.toFloat().coerceAtLeast(1f)
|
||||
} else {
|
||||
9f / 16f
|
||||
}
|
||||
videoAspectRatio = videoAspect
|
||||
|
||||
val smallerDim = minOf(screenWidth, screenHeight)
|
||||
windowWidth = (smallerDim * 0.35f).toInt().coerceIn(minWindowWidth, smallerDim / 2)
|
||||
windowHeight = (windowWidth / videoAspect).toInt()
|
||||
.coerceAtMost(screenHeight / 2).coerceAtLeast(minWindowHeight)
|
||||
}
|
||||
|
||||
// 悬浮窗大小:短边的 35%
|
||||
val windowWidth = (smallerDim * 0.35f).toInt().coerceIn(dp(120), smallerDim / 2)
|
||||
|
||||
// 计算初始位置:右上角
|
||||
// 计算初始位置:右上角(首次或位置未设置时)
|
||||
val screenWidth = metrics.widthPixels
|
||||
if (windowX == 0 && windowY == 0) {
|
||||
windowX = screenWidth - windowWidth - dp(16)
|
||||
windowY = dp(80)
|
||||
} else {
|
||||
// 确保窗口不超出屏幕边界
|
||||
windowX = windowX.coerceIn(0, screenWidth - windowWidth)
|
||||
}
|
||||
|
||||
windowParams.apply {
|
||||
x = windowX
|
||||
y = windowY
|
||||
width = windowWidth
|
||||
height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
height = windowHeight
|
||||
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
||||
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||
@@ -148,7 +184,7 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
try {
|
||||
windowManager.addView(windowView, windowParams)
|
||||
isShowing = true
|
||||
Log.i(TAG, "show(): window added at ($windowX, $windowY) size=$windowWidth")
|
||||
Log.i(TAG, "show(): window at ($windowX,$windowY) ${windowWidth}x${windowHeight}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "show(): failed to add window", e)
|
||||
}
|
||||
@@ -165,10 +201,12 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
sessionCollectJob?.cancel()
|
||||
sessionCollectJob = null
|
||||
|
||||
// 保存当前位置
|
||||
// 保存当前状态
|
||||
windowView?.let {
|
||||
windowX = windowParams.x
|
||||
windowY = windowParams.y
|
||||
windowWidth = windowParams.width
|
||||
windowHeight = windowParams.height
|
||||
}
|
||||
|
||||
// 先分离 Surface
|
||||
@@ -228,7 +266,7 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
val tv = TextureView(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
// 触摸事件:通过 TouchEventHandler 注入到远程设备
|
||||
setOnTouchListener { _, event ->
|
||||
@@ -282,6 +320,38 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
textureView = tv
|
||||
root.addView(tv)
|
||||
|
||||
// --- 顶部拖拽条 ---
|
||||
val dragBarHeight = dp(22)
|
||||
val dragBar = View(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
dragBarHeight,
|
||||
).apply {
|
||||
gravity = Gravity.TOP
|
||||
}
|
||||
setBackgroundColor(0x00000000.toInt())
|
||||
setOnTouchListener { _, event -> handleDragBarTouch(event) }
|
||||
}
|
||||
root.addView(dragBar)
|
||||
|
||||
// --- 右下角缩放手柄 ---
|
||||
val handleSize = dp(26)
|
||||
val resizeHandle = View(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(handleSize, handleSize).apply {
|
||||
gravity = Gravity.BOTTOM or Gravity.END
|
||||
}
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadii = floatArrayOf(
|
||||
0f, 0f, 0f, 0f, // tl, tr
|
||||
dp(4).toFloat(), 0f, 0f, 0f, // bl, br
|
||||
)
|
||||
setColor(0x60FFFFFF.toInt())
|
||||
}
|
||||
setOnTouchListener { _, event -> handleResizeTouch(event) }
|
||||
}
|
||||
root.addView(resizeHandle)
|
||||
|
||||
// --- 圆圈控制按钮 ---
|
||||
val circleSize = dp(36)
|
||||
val circleBg = GradientDrawable().apply {
|
||||
@@ -386,18 +456,104 @@ class FloatingVideoWindow(private val context: Context) {
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
// 点击:切换控制面板
|
||||
toggleControls()
|
||||
}
|
||||
// 保存位置
|
||||
windowX = windowParams.x
|
||||
windowY = windowParams.y
|
||||
saveWindowState()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拖拽条触摸事件,实现窗口拖动。
|
||||
*/
|
||||
private fun handleDragBarTouch(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
dragStartX = event.rawX
|
||||
dragStartY = event.rawY
|
||||
windowStartX = windowParams.x
|
||||
windowStartY = windowParams.y
|
||||
isDragging = true
|
||||
return true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val dx = event.rawX - dragStartX
|
||||
val dy = event.rawY - dragStartY
|
||||
windowParams.x = (windowStartX + dx).toInt()
|
||||
windowParams.y = (windowStartY + dy).toInt()
|
||||
windowView?.let { windowManager.updateViewLayout(it, windowParams) }
|
||||
return true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
saveWindowState()
|
||||
isDragging = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理缩放手柄触摸事件,实现按视频宽高比等比缩放。
|
||||
* 拖拽右下角手柄时,以宽度为基准,高度 = 宽度 ÷ 视频宽高比。
|
||||
*/
|
||||
private fun handleResizeTouch(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
isResizing = true
|
||||
resizeStartX = event.rawX
|
||||
resizeStartY = event.rawY
|
||||
resizeStartWidth = windowParams.width
|
||||
resizeStartHeight = windowParams.height
|
||||
return true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (!isResizing) return false
|
||||
val dx = (event.rawX - resizeStartX).toInt()
|
||||
// 以宽度为基准,按视频比例推导高度
|
||||
var newWidth = (resizeStartWidth + dx)
|
||||
.coerceIn(minWindowWidth, metrics.widthPixels)
|
||||
var newHeight = (newWidth / videoAspectRatio).toInt()
|
||||
|
||||
// 如果推导的高度超出范围,则以高度为基准反推宽度
|
||||
if (newHeight < minWindowHeight) {
|
||||
newHeight = minWindowHeight
|
||||
newWidth = (newHeight * videoAspectRatio).toInt()
|
||||
} else if (newHeight > metrics.heightPixels) {
|
||||
newHeight = metrics.heightPixels
|
||||
newWidth = (newHeight * videoAspectRatio).toInt()
|
||||
}
|
||||
|
||||
windowParams.width = newWidth
|
||||
windowParams.height = newHeight
|
||||
windowView?.let { windowManager.updateViewLayout(it, windowParams) }
|
||||
return true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
isResizing = false
|
||||
saveWindowState()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前窗口状态保存到字段,以便 hide/show 周期中保持。
|
||||
*/
|
||||
private fun saveWindowState() {
|
||||
windowX = windowParams.x
|
||||
windowY = windowParams.y
|
||||
windowWidth = windowParams.width
|
||||
windowHeight = windowParams.height
|
||||
}
|
||||
|
||||
private fun toggleControls() {
|
||||
val panel = controlPanel ?: return
|
||||
controlsVisible = !controlsVisible
|
||||
|
||||
4
build_apk.bat
Normal file
4
build_apk.bat
Normal file
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0build_apk.ps1" %*
|
||||
pause
|
||||
61
build_apk.ps1
Normal file
61
build_apk.ps1
Normal file
@@ -0,0 +1,61 @@
|
||||
param(
|
||||
[ValidateSet("debug", "release")]
|
||||
[string]$BuildType = "debug"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
# JDK 配置
|
||||
$JDK_HOME = "C:\Program Files\Eclipse Adoptium\jdk-11.0.31.11-hotspot"
|
||||
$env:JAVA_HOME = $JDK_HOME
|
||||
$env:PATH = "$JDK_HOME\bin;$env:PATH"
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " ScrcpyForAndroid APK Builder" -ForegroundColor Cyan
|
||||
Write-Host " Build Type: $BuildType" -ForegroundColor Yellow
|
||||
Write-Host " JDK: $JDK_HOME" -ForegroundColor Gray
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Push-Location $ScriptDir
|
||||
|
||||
try {
|
||||
# 停止旧 daemon(可能被 VS Code JRE 污染),失败也忽略
|
||||
Write-Host ">>> Stopping old Gradle daemon..." -ForegroundColor Gray
|
||||
.\gradlew.bat --stop 2>$null
|
||||
Write-Host ""
|
||||
|
||||
if ($BuildType -eq "release") {
|
||||
Write-Host ">>> Building Release APK..." -ForegroundColor Green
|
||||
.\gradlew.bat :app:assembleRelease --console=plain
|
||||
$apkDir = "app\build\outputs\apk\release"
|
||||
} else {
|
||||
Write-Host ">>> Building Debug APK..." -ForegroundColor Green
|
||||
.\gradlew.bat :app:assembleDebug --console=plain
|
||||
$apkDir = "app\build\outputs\apk\debug"
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " BUILD SUCCESSFUL!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
if (Test-Path $apkDir) {
|
||||
Get-ChildItem "$apkDir\*.apk" | ForEach-Object {
|
||||
$sizeMB = [math]::Round($_.Length / 1MB, 1)
|
||||
Write-Host " $($_.Name) ($sizeMB MB)" -ForegroundColor White
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " Output: $ScriptDir\$apkDir" -ForegroundColor Gray
|
||||
}
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "BUILD FAILED (exit code: $LASTEXITCODE)" -ForegroundColor Red
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -14,3 +14,4 @@ org.gradle.user.home=C\:\\Users\\zhimin.liu\\.gradle
|
||||
# org.gradle.parallel=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
org.gradle.java.home=C\:/Program Files/Eclipse Adoptium/jdk-11.0.31.11-hotspot
|
||||
Reference in New Issue
Block a user