diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt index fb3ba5c..5cdc884 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt @@ -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,8 +85,27 @@ class MainActivity: FragmentActivity() { enableEdgeToEdge() + val launchParams = AdbLaunchParams.fromIntent(intent) + setContent { - MainScreen() + 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 }, + ) + } + } } } @@ -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(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 { + 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) { + 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, + 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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt index 48a42f6..5f71fc0 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/StreamActivity.kt @@ -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,8 +28,15 @@ class StreamActivity: FragmentActivity() { currentActivityRef = WeakReference(this) AppScreenOn.register(window) + // 检测是否为快速启动模式(从 MainActivity 对话框直接跳转) + val isQuickLaunch = intent.hasExtra("host") + setContent { - StreamScreen(activity = this) + if (isQuickLaunch) { + QuickLaunchScreen(activity = this) + } else { + StreamScreen(activity = this) + } } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/QuickLaunchScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/QuickLaunchScreen.kt new file mode 100644 index 0000000..7261a40 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/QuickLaunchScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/FloatingVideoWindow.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/FloatingVideoWindow.kt index 0fe7030..48cc7fc 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/FloatingVideoWindow.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/FloatingVideoWindow.kt @@ -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) + } + + // 计算初始位置:右上角(首次或位置未设置时) val screenWidth = metrics.widthPixels - val screenHeight = metrics.heightPixels - val smallerDim = minOf(screenWidth, screenHeight) - - // 悬浮窗大小:短边的 35% - val windowWidth = (smallerDim * 0.35f).toInt().coerceIn(dp(120), smallerDim / 2) - - // 计算初始位置:右上角 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 diff --git a/gradle.properties b/gradle.properties index 9b040d1..0ea55a6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,4 +13,5 @@ org.gradle.user.home=C\:\\Users\\zhimin.liu\\.gradle # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +org.gradle.java.home=C\:/Program Files/Eclipse Adoptium/jdk-11.0.31.11-hotspot \ No newline at end of file