Compare commits

...

4 Commits

Author SHA1 Message Date
ca089428b8 feat(system):1.调整编译脚本. 2.用以下指令开启系统允许该程序浮窗功能:
adb shell appops set io.github.miuzarte.scrcpyforandroid SYSTEM_ALERT_WINDOW allow
2026-07-17 19:15:05 +08:00
37fa52fbf6 feat(build): 添加 bat 包装脚本,解决 PowerShell 执行策略限制 2026-07-17 17:24:00 +08:00
a336734897 feat(build): 添加本地编译脚本,绕过沙箱限制 2026-07-17 17:21:25 +08:00
58eefc88c6 feat(ui): 启动弹窗替代QuickConnectScreen,悬浮窗支持拖动和缩放
- 启动默认显示QuickConnectDialog弹窗(连接/无线配对/更多设置)
- 弹窗包含IP+端口输入、历史记录、无线配对子界面
- '更多设置'按钮可进入主配置界面
- 悬浮窗新增顶部拖拽条(拖动位置)和右下角手柄(缩放窗口)
- 缩放时锁定视频原始宽高比,防止变形
- 位置和尺寸在hide/show周期中持久化
- 移除系统PiP,统一使用自定义悬浮窗方案
2026-07-17 17:15:16 +08:00
7 changed files with 868 additions and 24 deletions

View File

@@ -1,13 +1,30 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Rect import android.graphics.Rect
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.core.content.edit
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams 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.PasswordRepository
import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime 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.services.AppScreenOn
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale import java.util.Locale
// 生物认证需要 FragmentActivity // 生物认证需要 FragmentActivity
@@ -45,11 +66,6 @@ class MainActivity: FragmentActivity() {
AppRuntime.init(applicationContext) AppRuntime.init(applicationContext)
AppScreenOn.register(window) AppScreenOn.register(window)
// 解析命令行启动参数adb shell am start --es host ...
AdbLaunchParams.fromIntent(intent)?.let {
AppRuntime.adbLaunchParams = it
}
runBlocking { runBlocking {
PasswordRepository.refresh() PasswordRepository.refresh()
val cached = getAppLanguageTag(applicationContext) val cached = getAppLanguageTag(applicationContext)
@@ -69,8 +85,27 @@ class MainActivity: FragmentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
val launchParams = AdbLaunchParams.fromIntent(intent)
setContent { 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() 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 { internal companion object {
private const val PHONE_LANDSCAPE_LOCK_ASPECT_RATIO = 16f / 9f private const val PHONE_LANDSCAPE_LOCK_ASPECT_RATIO = 16f / 9f
private const val LOCALE_PREFS = "locale_cache" private const val LOCALE_PREFS = "locale_cache"
private const val KEY_LANGUAGE_TAG = "language_tag" 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) = fun getAppLanguageTag(context: Context) =
context.getSharedPreferences(LOCALE_PREFS, MODE_PRIVATE) context.getSharedPreferences(LOCALE_PREFS, MODE_PRIVATE)

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import io.github.miuzarte.scrcpyforandroid.pages.QuickLaunchScreen
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
@@ -19,7 +20,7 @@ import java.lang.ref.WeakReference
class StreamActivity: FragmentActivity() { class StreamActivity: FragmentActivity() {
private val floatingWindow by lazy { FloatingVideoWindow(this) } internal val floatingWindow by lazy { FloatingVideoWindow(this) }
private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -27,8 +28,15 @@ class StreamActivity: FragmentActivity() {
currentActivityRef = WeakReference(this) currentActivityRef = WeakReference(this)
AppScreenOn.register(window) AppScreenOn.register(window)
// 检测是否为快速启动模式(从 MainActivity 对话框直接跳转)
val isQuickLaunch = intent.hasExtra("host")
setContent { setContent {
StreamScreen(activity = this) if (isQuickLaunch) {
QuickLaunchScreen(activity = this)
} else {
StreamScreen(activity = this)
}
} }
} }

View File

@@ -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,
)
}
}
}

View File

@@ -65,6 +65,9 @@ class FloatingVideoWindow(private val context: Context) {
// 窗口位置 // 窗口位置
private var windowX: Int = 0 private var windowX: Int = 0
private var windowY: Int = 0 private var windowY: Int = 0
// 窗口尺寸持久化hide/show 之间保持)
private var windowWidth: Int = 0
private var windowHeight: Int = 0
// 拖动相关 // 拖动相关
private var dragStartX = 0f private var dragStartX = 0f
@@ -73,6 +76,19 @@ class FloatingVideoWindow(private val context: Context) {
private var windowStartY = 0 private var windowStartY = 0
private var isDragging = false 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 onCloseCallback: (() -> Unit)? = null
private var onFullscreenCallback: (() -> Unit)? = null private var onFullscreenCallback: (() -> Unit)? = null
@@ -108,6 +124,9 @@ class FloatingVideoWindow(private val context: Context) {
/** /**
* 显示悬浮窗。 * 显示悬浮窗。
* 在 Activity.onStop() 时调用。 * 在 Activity.onStop() 时调用。
*
* 窗口尺寸根据远程设备视频宽高比自适应计算,
* 确保悬浮窗保持与手机屏幕相同的比例。
*/ */
fun show(onClose: () -> Unit, onFullscreen: () -> Unit) { fun show(onClose: () -> Unit, onFullscreen: () -> Unit) {
if (isShowing) return if (isShowing) return
@@ -122,24 +141,41 @@ class FloatingVideoWindow(private val context: Context) {
createWindowView() createWindowView()
setupTouchHandler() 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 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) { if (windowX == 0 && windowY == 0) {
windowX = screenWidth - windowWidth - dp(16) windowX = screenWidth - windowWidth - dp(16)
windowY = dp(80) windowY = dp(80)
} else {
// 确保窗口不超出屏幕边界
windowX = windowX.coerceIn(0, screenWidth - windowWidth)
} }
windowParams.apply { windowParams.apply {
x = windowX x = windowX
y = windowY y = windowY
width = windowWidth width = windowWidth
height = ViewGroup.LayoutParams.WRAP_CONTENT height = windowHeight
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
@@ -148,7 +184,7 @@ class FloatingVideoWindow(private val context: Context) {
try { try {
windowManager.addView(windowView, windowParams) windowManager.addView(windowView, windowParams)
isShowing = true 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) { } catch (e: Exception) {
Log.e(TAG, "show(): failed to add window", e) Log.e(TAG, "show(): failed to add window", e)
} }
@@ -165,10 +201,12 @@ class FloatingVideoWindow(private val context: Context) {
sessionCollectJob?.cancel() sessionCollectJob?.cancel()
sessionCollectJob = null sessionCollectJob = null
// 保存当前位置 // 保存当前状态
windowView?.let { windowView?.let {
windowX = windowParams.x windowX = windowParams.x
windowY = windowParams.y windowY = windowParams.y
windowWidth = windowParams.width
windowHeight = windowParams.height
} }
// 先分离 Surface // 先分离 Surface
@@ -228,7 +266,7 @@ class FloatingVideoWindow(private val context: Context) {
val tv = TextureView(context).apply { val tv = TextureView(context).apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.MATCH_PARENT,
) )
// 触摸事件:通过 TouchEventHandler 注入到远程设备 // 触摸事件:通过 TouchEventHandler 注入到远程设备
setOnTouchListener { _, event -> setOnTouchListener { _, event ->
@@ -282,6 +320,38 @@ class FloatingVideoWindow(private val context: Context) {
textureView = tv textureView = tv
root.addView(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 circleSize = dp(36)
val circleBg = GradientDrawable().apply { val circleBg = GradientDrawable().apply {
@@ -386,18 +456,104 @@ class FloatingVideoWindow(private val context: Context) {
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
if (!isDragging) { if (!isDragging) {
// 点击:切换控制面板
toggleControls() toggleControls()
} }
// 保存位置 saveWindowState()
windowX = windowParams.x
windowY = windowParams.y
return true return true
} }
} }
return false 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() { private fun toggleControls() {
val panel = controlPanel ?: return val panel = controlPanel ?: return
controlsVisible = !controlsVisible controlsVisible = !controlsVisible

4
build_apk.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
powershell -ExecutionPolicy Bypass -File "%~dp0build_apk.ps1" %*
pause

61
build_apk.ps1 Normal file
View 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
}

View File

@@ -13,4 +13,5 @@ org.gradle.user.home=C\:\\Users\\zhimin.liu\\.gradle
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true # org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
org.gradle.java.home=C\:/Program Files/Eclipse Adoptium/jdk-11.0.31.11-hotspot