Compare commits

...

14 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
db6c4b9bb8 feat():实现命令行启动 2026-07-16 21:58:53 +08:00
491badd2aa feat():悬浮窗方案 替代 系统PiP方案 2026-07-15 21:34:27 +08:00
4c7b24a4d5 feat(env):update compile env. 2026-07-15 20:12:38 +08:00
f7ff48ff6c Merge branch 'main' of https://github.com/Miuzarte/ScrcpyForAndroid 2026-07-15 19:25:28 +08:00
renovate[bot]
b1ba9fcbea chore(deps): update actions/setup-java action to v5 (#58)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:46:12 +08:00
renovate[bot]
85f4e50aad chore(deps): update actions/checkout action to v7 (#57)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:46:01 +08:00
renovate[bot]
190da2b0ab chore(deps): update actions/cache action to v6 (#56)
* chore(deps): ignore miuix in renovatebot

* chore(deps): update actions/cache action to v6

---------

Co-authored-by: Miuzarte <982809597@qq.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:45:47 +08:00
renovate[bot]
f92467700c fix(deps): update dependency org.conscrypt:conscrypt-android to v2.6.1 (#55)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:45:09 +08:00
renovate[bot]
12f9effd8f fix(deps): update dependency org.bouncycastle:bcpkix-jdk18on to v1.85 (#54)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 14:44:40 +08:00
Miuzarte
c9cb5b1034 fix: packaging excludes 2026-07-14 14:42:02 +08:00
20 changed files with 1584 additions and 210 deletions

View File

@@ -19,19 +19,19 @@ jobs:
RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
cache: gradle cache: gradle
- uses: actions/cache@v4 - uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -40,7 +40,7 @@ jobs:
key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-deps- restore-keys: gradle-deps-
- uses: actions/cache@v4 - uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches/build-cache-* ~/.gradle/caches/build-cache-*
@@ -98,19 +98,19 @@ jobs:
RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
cache: gradle cache: gradle
- uses: actions/cache@v4 - uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -119,7 +119,7 @@ jobs:
key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-deps- restore-keys: gradle-deps-
- uses: actions/cache@v4 - uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches/build-cache-* ~/.gradle/caches/build-cache-*

View File

@@ -11,19 +11,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
cache: gradle cache: gradle
- uses: actions/cache@v4 - uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -32,7 +32,7 @@ jobs:
key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-deps- restore-keys: gradle-deps-
- uses: actions/cache@v4 - uses: actions/cache@v6
with: with:
path: | path: |
~/.gradle/caches/build-cache-* ~/.gradle/caches/build-cache-*

View File

@@ -106,6 +106,17 @@ android {
buildConfig = true buildConfig = true
} }
packaging {
resources {
excludes += listOf(
"META-INF/LICENSE.md",
"META-INF/LICENSE",
"META-INF/NOTICE.md",
"META-INF/NOTICE",
)
}
}
externalNativeBuild { externalNativeBuild {
cmake { cmake {
path = file("src/main/jni/CMakeLists.txt") path = file("src/main/jni/CMakeLists.txt")

View File

@@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -36,8 +37,7 @@
android:name=".StreamActivity" android:name=".StreamActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize" android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
android:exported="false" android:exported="false"
android:resizeableActivity="true" android:resizeableActivity="true" />
android:supportsPictureInPicture="true" />
<activity <activity
android:name=".LockscreenPasswordActivity" android:name=".LockscreenPasswordActivity"

View File

@@ -1,23 +1,45 @@
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.pages.MainScreen import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate 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
@@ -63,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 },
)
}
}
} }
} }
@@ -101,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

@@ -1,150 +1,89 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.R.drawable
import android.app.PictureInPictureUiState
import android.app.RemoteAction
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Icon
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.core.app.PictureInPictureParamsCompat.Builder
import androidx.core.content.ContextCompat
import androidx.core.pip.BasicPictureInPicture
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.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver import io.github.miuzarte.scrcpyforandroid.widgets.FloatingVideoWindow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
class StreamActivity: FragmentActivity() { class StreamActivity: FragmentActivity() {
private val basicPip by lazy { BasicPictureInPicture(this, ContextCompat.getMainExecutor(this)) }
private val pipActionReceiver = PictureInPictureActionReceiver() internal val floatingWindow by lazy { FloatingVideoWindow(this) }
private var isPipActionReceiverRegistered = false private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// 是否处于 pip
// 回到全屏时会因重建而变回初始值
private val _pipModeState = MutableStateFlow(false)
val pipModeState: StateFlow<Boolean> = _pipModeState
val pipStopAction: RemoteAction by lazy {
RemoteAction(
Icon.createWithResource(this, drawable.ic_menu_close_clear_cancel),
getString(R.string.password_stop_mirroring),
getString(R.string.password_stop_mirroring),
PictureInPictureActionReceiver.createPendingIntent(this),
)
}
// 每次 进出全屏/进出画中画
// 都会重建 activity
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
currentActivityRef = WeakReference(this) currentActivityRef = WeakReference(this)
AppScreenOn.register(window) AppScreenOn.register(window)
registerPipActionReceiver() // 检测是否为快速启动模式(从 MainActivity 对话框直接跳转)
val isQuickLaunch = intent.hasExtra("host")
// 声明要画中画
basicPip.setEnabled(true)
setContent { setContent {
StreamScreen(activity = this) if (isQuickLaunch) {
} QuickLaunchScreen(activity = this)
} else {
/* StreamScreen(activity = this)
// 可能以后有用
basicPip.addOnPictureInPictureEventListener(
executor = mainExecutor,
listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener {
override fun onPictureInPictureEvent(
event: PictureInPictureDelegate.Event,
config: Configuration?,
) {
// MIUI 只有这些事件
when (event) {
PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> {}
PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {}
PictureInPictureDelegate.Event.STASHED -> {}
PictureInPictureDelegate.Event.UNSTASHED -> {}
// 收不到
// PictureInPictureDelegate.Event.ENTERED -> {}
// PictureInPictureDelegate.Event.EXITED -> {}
}
}
} }
) }
*/
} }
fun configurePip(block: Builder.() -> Builder) = override fun onStart() {
basicPip.setPictureInPictureParams(Builder().block().build()) super.onStart()
// 回到前台时隐藏悬浮窗
floatingWindow.hide()
}
override fun onStop() {
super.onStop()
if (AppRuntime.scrcpy?.currentSessionState?.value != null) {
// 进入后台时显示悬浮窗
floatingWindow.show(
onClose = {
// 关闭:停止 scrcpy 会话
activityScope.launch(Dispatchers.IO) {
AppRuntime.scrcpy?.stop(Scrcpy.StopReason.USER)
}
finish()
},
onFullscreen = {
// 全屏:重新打开 StreamActivity
if (!isFinishing && !isDestroyed) {
// Activity 仍在,直接将其带到前台
startActivity(createIntent(this).apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
})
} else {
// Activity 已销毁,创建新的
startActivity(createIntent(this))
}
},
)
}
}
override fun onDestroy() { override fun onDestroy() {
currentActivityRef?.get() currentActivityRef?.get()
?.takeIf { it === this } ?.takeIf { it === this }
?.let { currentActivityRef = null } ?.let { currentActivityRef = null }
AppScreenOn.unregister(window) AppScreenOn.unregister(window)
unregisterPipActionReceiver() floatingWindow.destroy()
activityScope.cancel()
super.onDestroy() super.onDestroy()
} }
/*
// 回到全屏也会停止, 暂时不做
override fun onDestroy() {
super.onDestroy()
if (_pipModeState.value) {
Thread {
runBlocking {
AppRuntime.scrcpy?.stop()
}
}.start()
}
}
*/
//- onPictureInPictureModeChanged
//+ onPictureInPictureUiStateChanged
//- onUserLeaveHint
override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
super.onPictureInPictureUiStateChanged(pipState)
_pipModeState.value = true
/*
when {
// 进入画中画
pipState.isTransitioningToPip -> {}
// 收进边缘
pipState.isStashed -> {}
}
*/
}
private fun registerPipActionReceiver() {
if (isPipActionReceiverRegistered) return
ContextCompat.registerReceiver(
this,
pipActionReceiver,
PictureInPictureActionReceiver.createIntentFilter(),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
isPipActionReceiverRegistered = true
}
private fun unregisterPipActionReceiver() {
if (!isPipActionReceiverRegistered) return
unregisterReceiver(pipActionReceiver)
isPipActionReceiverRegistered = false
}
companion object { companion object {
private var currentActivityRef: WeakReference<StreamActivity>? = null private var currentActivityRef: WeakReference<StreamActivity>? = null
@@ -153,9 +92,8 @@ class StreamActivity: FragmentActivity() {
} }
fun dismissActivePictureInPicture() { fun dismissActivePictureInPicture() {
currentActivityRef?.get() // 悬浮窗模式:不再使用系统 PiP改为关闭悬浮窗
?.takeIf { it.isInPictureInPictureMode } currentActivityRef?.get()?.floatingWindow?.hide()
?.finish()
} }
} }
} }

View File

@@ -0,0 +1,72 @@
package io.github.miuzarte.scrcpyforandroid.models
import android.content.Intent
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* 从命令行 Intent extras 中解析的启动参数。
*
* 使用方式adb shell
* ```
* adb shell am start -n io.github.miuzarte.scrcpyforandroid/.MainActivity \
* --es host "192.168.1.100" \
* --ei port 5555 \
* --ei maxSize 1920 \
* --ei videoBitRate 8000000 \
* --es maxFps "60" \
* --ez fullscreen true \
* --es startApp "com.example.app"
* ```
*/
@Parcelize
data class AdbLaunchParams(
/** 目标设备 IP/主机名(必填) */
val host: String,
/** ADB 端口,默认 5555 */
val port: Int = 5555,
/** 视频最大分辨率0 表示不限制 */
val maxSize: Int = 0,
/** 视频码率bps0 表示使用默认值 */
val videoBitRate: Int = 0,
/** 最大帧率,空字符串表示不限制 */
val maxFps: String = "",
/** 启动后自动进入全屏 */
val fullscreen: Boolean = false,
/** 在远程设备上启动指定应用包名 */
val startApp: String = "",
/** 显示触摸点 */
val showTouches: Boolean = false,
/** 启动后关闭设备屏幕 */
val turnScreenOff: Boolean = false,
/** 启用触控操作,默认 true */
val control: Boolean = true,
/** 启用视频流,默认 true */
val video: Boolean = true,
/** 启用音频流,默认 true */
val audio: Boolean = true,
/** 关闭后断掉 ADB 连接 */
val killAdbOnClose: Boolean = false,
) : Parcelable {
companion object {
fun fromIntent(intent: Intent?): AdbLaunchParams? {
intent ?: return null
val host = intent.getStringExtra("host")?.takeIf { it.isNotBlank() } ?: return null
return AdbLaunchParams(
host = host,
port = intent.getIntExtra("port", 5555),
maxSize = intent.getIntExtra("maxSize", 0),
videoBitRate = intent.getIntExtra("videoBitRate", 0),
maxFps = intent.getStringExtra("maxFps") ?: "",
fullscreen = intent.getBooleanExtra("fullscreen", false),
startApp = intent.getStringExtra("startApp") ?: "",
showTouches = intent.getBooleanExtra("showTouches", false),
turnScreenOff = intent.getBooleanExtra("turnScreenOff", false),
control = intent.getBooleanExtra("control", true),
video = intent.getBooleanExtra("video", true),
audio = intent.getBooleanExtra("audio", true),
killAdbOnClose = intent.getBooleanExtra("killAdbOnClose", false),
)
}
}
}

View File

@@ -230,6 +230,7 @@ internal fun DeviceTabPage(
LaunchedEffect(Unit) { viewModel.startAutoReconnectLoop() } LaunchedEffect(Unit) { viewModel.startAutoReconnectLoop() }
LaunchedEffect(Unit) { viewModel.startProfileIdSync() } LaunchedEffect(Unit) { viewModel.startProfileIdSync() }
LaunchedEffect(Unit) { viewModel.startRecentTasksAutoRefresh() } LaunchedEffect(Unit) { viewModel.startRecentTasksAutoRefresh() }
LaunchedEffect(Unit) { viewModel.processLaunchParams() }
fun openFullscreenControl() { fun openFullscreenControl() {
if (viewModel.shouldOpenFullscreenCompat()) if (viewModel.shouldOpenFullscreenCompat())

View File

@@ -7,9 +7,11 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.github.miuzarte.scrcpyforandroid.R import io.github.miuzarte.scrcpyforandroid.R
import io.github.miuzarte.scrcpyforandroid.StreamActivity import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.* import io.github.miuzarte.scrcpyforandroid.services.*
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
@@ -512,6 +514,7 @@ internal class DeviceTabViewModel(
autoStartScrcpy: Boolean = false, autoStartScrcpy: Boolean = false,
autoEnterFullScreen: Boolean = false, autoEnterFullScreen: Boolean = false,
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID, scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
scrcpyOptionsOverride: ((ClientOptions) -> ClientOptions)? = null,
) { ) {
val connected = connectionController.handleAdbConnected(host, port, scrcpyProfileId) val connected = connectionController.handleAdbConnected(host, port, scrcpyProfileId)
val info = connected.info val info = connected.info
@@ -551,7 +554,10 @@ internal class DeviceTabViewModel(
if (autoStartScrcpy && sessionInfo.value == null) { if (autoStartScrcpy && sessionInfo.value == null) {
runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) { runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) {
startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen) startScrcpySession(
openFullscreen = autoStartScrcpy && autoEnterFullScreen,
extraOptions = scrcpyOptionsOverride,
)
} }
} }
} }
@@ -559,13 +565,21 @@ internal class DeviceTabViewModel(
suspend fun startScrcpySession( suspend fun startScrcpySession(
openFullscreen: Boolean = false, openFullscreen: Boolean = false,
startAppOverride: String? = null, startAppOverride: String? = null,
extraOptions: ((ClientOptions) -> ClientOptions)? = null,
) { ) {
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value) val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value)
val options = scrcpyOptions.toClientOptions(activeBundle).fix() val options = scrcpyOptions.toClientOptions(activeBundle).fix()
val resolvedOptions = startAppOverride val resolvedOptions = extraOptions?.invoke(
?.takeIf { it.isNotBlank() } startAppOverride
?.let { options.copy(startApp = it) } ?.takeIf { it.isNotBlank() }
?: options ?.let { options.copy(startApp = it) }
?: options
) ?: (
startAppOverride
?.takeIf { it.isNotBlank() }
?.let { options.copy(startApp = it) }
?: options
)
val session = scrcpy.start(resolvedOptions) val session = scrcpy.start(resolvedOptions)
_pendingScrollToPreview.value = resolvedOptions.video && resolvedOptions.videoPlayback _pendingScrollToPreview.value = resolvedOptions.video && resolvedOptions.videoPlayback
@@ -786,6 +800,52 @@ internal class DeviceTabViewModel(
} }
} }
/**
* 处理命令行启动参数:连接到指定设备并自动启动 scrcpy。
* 从 AppRuntime.adbLaunchParams 读取参数,用后即清。
*/
fun processLaunchParams() {
val params = AppRuntime.adbLaunchParams ?: return
AppRuntime.adbLaunchParams = null // consume
runAdbConnect(
label = EventLogMessage.Raw("Auto-connect"),
) {
disconnectCurrentTargetBeforeConnecting(params.host, params.port)
try {
connectWithTimeout(params.host, params.port)
handleAdbConnected(
host = params.host,
port = params.port,
autoStartScrcpy = true,
autoEnterFullScreen = params.fullscreen,
scrcpyOptionsOverride = { options ->
options.copy(
maxSize =
if (params.maxSize > 0) params.maxSize.toUShort() else options.maxSize,
videoBitRate =
if (params.videoBitRate > 0) params.videoBitRate else options.videoBitRate,
maxFps = params.maxFps.ifBlank { options.maxFps },
showTouches = params.showTouches,
fullscreen = params.fullscreen,
control = params.control,
video = params.video,
audio = params.audio,
turnScreenOff = params.turnScreenOff,
killAdbOnClose = params.killAdbOnClose,
startApp = params.startApp.ifBlank { options.startApp },
)
},
)
connectionController.updateQuickConnected(false)
} catch (error: Exception) {
connectionController.markConnectionFailed(error)
logEvent(R.string.vm_adb_connection_failed, level = Log.ERROR, error = error)
AppRuntime.snackbar(R.string.vm_adb_connection_failed)
}
}
}
fun onDisconnectCurrent(target: ConnectionTarget?) { fun onDisconnectCurrent(target: ConnectionTarget?) {
runAdbConnect(EventLogMessage.Resource(R.string.vm_disconnect_adb)) { runAdbConnect(EventLogMessage.Resource(R.string.vm_disconnect_adb)) {
target?.let { target?.let {

View File

@@ -56,7 +56,6 @@ import top.yukonga.miuix.kmp.basic.Text
fun FullscreenControlScreen( fun FullscreenControlScreen(
scrcpy: Scrcpy, scrcpy: Scrcpy,
onBack: () -> Unit, onBack: () -> Unit,
isInPip: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit, onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onVideoBoundsInWindowChanged: (Rect?) -> Unit, onVideoBoundsInWindowChanged: (Rect?) -> Unit,
) { ) {
@@ -388,15 +387,17 @@ fun FullscreenControlScreen(
.padding(contentPadding), .padding(contentPadding),
) { ) {
val session = currentSession ?: return@Box val session = currentSession ?: return@Box
var touchEventHandlerRef by remember { mutableStateOf<TouchEventHandler?>(null) }
FullscreenControlPage( FullscreenControlPage(
scrcpy = scrcpy, scrcpy = scrcpy,
session = session, session = session,
onDismiss = onBack, onDismiss = onBack,
showDebugInfo = fullscreenDebugInfo && !isInPip, showDebugInfo = fullscreenDebugInfo,
currentFps = currentFps, currentFps = currentFps,
imeRequestToken = imeRequestToken, imeRequestToken = imeRequestToken,
enableBackHandler = false, enableBackHandler = false,
interactive = !isInPip, interactive = true,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged, onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onImeCommitText = ::commitImeText, onImeCommitText = ::commitImeText,
onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons -> onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
@@ -417,9 +418,10 @@ fun FullscreenControlScreen(
onBackOrScreenOn = { action -> onBackOrScreenOn = { action ->
withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) } withContext(Dispatchers.IO) { scrcpy.pressBackOrTurnScreenOn(action) }
}, },
onTouchEventHandlerCreated = { touchEventHandlerRef = it },
) )
if (showFullscreenVirtualButtons && !isInPip) { if (showFullscreenVirtualButtons) {
bar.Fullscreen( bar.Fullscreen(
modifier = Modifier.align( modifier = Modifier.align(
when (fullscreenVirtualButtonDock) { when (fullscreenVirtualButtonDock) {
@@ -439,7 +441,7 @@ fun FullscreenControlScreen(
) )
} }
if (asBundle.showFullscreenFloatingButton && !isInPip) { if (asBundle.showFullscreenFloatingButton) {
bar.FloatingBall( bar.FloatingBall(
actions = floatingActions, actions = floatingActions,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -626,6 +628,7 @@ fun FullscreenControlPage(
buttons: Int, buttons: Int,
) -> Unit, ) -> Unit,
onBackOrScreenOn: suspend (action: Int) -> Unit, onBackOrScreenOn: suspend (action: Int) -> Unit,
onTouchEventHandlerCreated: ((TouchEventHandler?) -> Unit)? = null,
) { ) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss) BackHandler(enabled = enableBackHandler, onBack = onDismiss)
@@ -661,6 +664,13 @@ fun FullscreenControlPage(
) )
} }
DisposableEffect(touchEventHandler) {
onTouchEventHandlerCreated?.invoke(touchEventHandler)
onDispose {
onTouchEventHandlerCreated?.invoke(null)
}
}
val resizeDebouncer = remember { val resizeDebouncer = remember {
Debouncer(300L) { w, h -> Debouncer(300L) { w, h ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {

View File

@@ -662,7 +662,6 @@ fun MainScreen() {
FullscreenControlRoute( FullscreenControlRoute(
scrcpy = scrcpy, scrcpy = scrcpy,
onBack = rootNavigator.pop, onBack = rootNavigator.pop,
isInPip = false,
autoExitOnStop = true, autoExitOnStop = true,
) )
} }

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

@@ -2,7 +2,6 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.graphics.Rect import android.graphics.Rect
import android.util.Rational
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -15,8 +14,6 @@ import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.ui.createThemeController import io.github.miuzarte.scrcpyforandroid.ui.createThemeController
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTargetState
import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -25,51 +22,6 @@ fun StreamScreen(activity: StreamActivity) {
val scrcpy = remember { AppRuntime.scrcpy!! } val scrcpy = remember { AppRuntime.scrcpy!! }
val asBundle by Storage.appSettings.bundleState.collectAsState() val asBundle by Storage.appSettings.bundleState.collectAsState()
val isInPip by activity.pipModeState.collectAsState()
var pipSourceRectHint by remember { mutableStateOf<Rect?>(null) }
var lastPipAspectRatio by remember { mutableStateOf<Rational?>(null) }
var lastPipOrientationLandscape by remember { mutableStateOf<Boolean?>(null) }
DisposableEffect(isInPip) {
onDispose {
VideoOutputTargetState.set(VideoOutputTarget.PREVIEW)
}
}
val currentSession by scrcpy.currentSessionState.collectAsState()
LaunchedEffect(
activity, isInPip,
currentSession?.width, currentSession?.height,
) {
val session = currentSession ?: return@LaunchedEffect
val isLandscape = session.width >= session.height
if (lastPipAspectRatio != null && lastPipOrientationLandscape == isLandscape) {
// 一定要只在视频比例变更时才更新,
// .setAspectRatio() 多次传递相同的值时,
// 内部会自行应用其倒数
return@LaunchedEffect
}
lastPipOrientationLandscape = isLandscape
val pipAspectRatio = Rational(
session.width.coerceAtLeast(1),
session.height.coerceAtLeast(1),
).also { ratio ->
lastPipAspectRatio = ratio
}
activity.configurePip {
setEnabled(true)
setAspectRatio(pipAspectRatio)
setSourceRectHint(pipSourceRectHint)
setSeamlessResizeEnabled(true)
setCloseAction(activity.pipStopAction)
}
}
val themeController = remember( val themeController = remember(
asBundle.themeBaseIndex, asBundle.themeBaseIndex,
asBundle.monet, asBundle.monet,
@@ -99,11 +51,6 @@ fun StreamScreen(activity: StreamActivity) {
FullscreenControlRoute( FullscreenControlRoute(
scrcpy = scrcpy, scrcpy = scrcpy,
onBack = activity::finish, onBack = activity::finish,
isInPip = isInPip,
onVideoBoundsInWindowChanged = {
// 记录下一次进入 PiP 时可用的 sourceRectHint
pipSourceRectHint = it
},
) )
} }
} }
@@ -113,7 +60,6 @@ fun StreamScreen(activity: StreamActivity) {
fun FullscreenControlRoute( fun FullscreenControlRoute(
scrcpy: Scrcpy, scrcpy: Scrcpy,
onBack: () -> Unit, onBack: () -> Unit,
isInPip: Boolean = false,
autoExitOnStop: Boolean = false, autoExitOnStop: Boolean = false,
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {}, onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
) { ) {
@@ -153,16 +99,13 @@ fun FullscreenControlRoute(
FullscreenControlScreen( FullscreenControlScreen(
scrcpy = scrcpy, scrcpy = scrcpy,
onBack = onBack, onBack = onBack,
isInPip = isInPip,
onVideoSizeChanged = { width, height -> onVideoSizeChanged = { width, height ->
if (!isInPip) { activity?.requestedOrientation =
activity?.requestedOrientation = fullscreenRequestedOrientation(
fullscreenRequestedOrientation( width = width,
width = width, height = height,
height = height, ignoreSystemRotationLock = asBundle.fullscreenControlIgnoreSystemRotationLock,
ignoreSystemRotationLock = asBundle.fullscreenControlIgnoreSystemRotationLock, )
)
}
}, },
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged, onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
) )

View File

@@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -28,6 +29,7 @@ object AppRuntime {
var scrcpy: Scrcpy? = null var scrcpy: Scrcpy? = null
var currentConnectionTarget: ConnectionTarget? = null var currentConnectionTarget: ConnectionTarget? = null
var currentConnectedDevice: ConnectedDeviceInfo? = null var currentConnectedDevice: ConnectedDeviceInfo? = null
var adbLaunchParams: AdbLaunchParams? = null
private val snackbarHostStateLock = Any() private val snackbarHostStateLock = Any()
private val snackbarHostStateStack = mutableListOf<SnackbarHostState>() private val snackbarHostStateStack = mutableListOf<SnackbarHostState>()

View File

@@ -0,0 +1,651 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.PixelFormat
import android.graphics.SurfaceTexture
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.view.*
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
/**
* 悬浮窗管理器,替代系统画中画(PiP)。
*
* 使用 WindowManager 创建 TYPE_APPLICATION_OVERLAY 悬浮窗口,
* 包含视频渲染(TextureView)、触摸事件处理和最小化控制按钮。
*
* 生命周期:
* - [show] 当 Activity 进入后台时调用,显示悬浮窗
* - [hide] 当 Activity 回到前台时调用,隐藏悬浮窗
* - [destroy] 彻底清理,释放所有资源
*/
class FloatingVideoWindow(private val context: Context) {
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val metrics = context.resources.displayMetrics
private var windowView: FrameLayout? = null
private var textureView: TextureView? = null
private var controlCircle: View? = null
private var controlPanel: LinearLayout? = null
private var closeBtn: TextView? = null
private var fullscreenBtn: TextView? = null
private var currentSurface: android.view.Surface? = null
private var touchEventHandler: TouchEventHandler? = null
// 触摸状态持有者(独立于 Compose但与 TouchEventHandler 使用相同类型)
private val activePointerIds = LinkedHashSet<Int>()
private val activePointerPositions = LinkedHashMap<Int, Offset>()
private val activePointerDevicePositions = LinkedHashMap<Int, Pair<Int, Int>>()
private val pointerLabels = LinkedHashMap<Int, Int>()
private var nextPointerLabel = 1
private var touchAreaSize = IntSize.Zero
private var isShowing = false
private var controlsVisible = false
private var sessionCollectJob: Job? = null
// 窗口位置
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
private var dragStartY = 0f
private var windowStartX = 0
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
companion object {
private const val TAG = "FloatingVideoWindow"
private const val CONTROL_AUTO_HIDE_MS = 3000L
}
/**
* 检查悬浮窗权限是否已授予。
*/
fun canDrawOverlays(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(context)
} else {
true
}
}
/**
* 打开悬浮窗权限设置页面。
*/
fun requestOverlayPermission(activity: Activity, requestCode: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
)
activity.startActivityForResult(intent, requestCode)
}
}
/**
* 显示悬浮窗。
* 在 Activity.onStop() 时调用。
*
* 窗口尺寸根据远程设备视频宽高比自适应计算,
* 确保悬浮窗保持与手机屏幕相同的比例。
*/
fun show(onClose: () -> Unit, onFullscreen: () -> Unit) {
if (isShowing) return
if (!canDrawOverlays()) {
Log.w(TAG, "show(): SYSTEM_ALERT_WINDOW permission not granted")
return
}
onCloseCallback = onClose
onFullscreenCallback = onFullscreen
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
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 = windowHeight
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
}
try {
windowManager.addView(windowView, windowParams)
isShowing = true
Log.i(TAG, "show(): window at ($windowX,$windowY) ${windowWidth}x${windowHeight}")
} catch (e: Exception) {
Log.e(TAG, "show(): failed to add window", e)
}
}
/**
* 隐藏悬浮窗。
* 在 Activity.onStart() 时调用。
*/
fun hide() {
if (!isShowing) return
Log.i(TAG, "hide(): removing window")
sessionCollectJob?.cancel()
sessionCollectJob = null
// 保存当前状态
windowView?.let {
windowX = windowParams.x
windowY = windowParams.y
windowWidth = windowParams.width
windowHeight = windowParams.height
}
// 先分离 Surface
currentSurface?.let { surface ->
scope.launch {
NativeCoreFacade.detachVideoSurface(surface)
}
currentSurface = null
}
try {
windowView?.let { windowManager.removeView(it) }
} catch (e: Exception) {
Log.w(TAG, "hide(): failed to remove view", e)
}
windowView = null
textureView = null
controlCircle = null
controlPanel = null
closeBtn = null
fullscreenBtn = null
touchEventHandler = null
isShowing = false
controlsVisible = false
}
/**
* 彻底销毁悬浮窗并清理资源。
*/
fun destroy() {
hide()
scope.cancel()
onCloseCallback = null
onFullscreenCallback = null
}
private val windowParams = WindowManager.LayoutParams().apply {
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.TYPE_PHONE
}
format = PixelFormat.TRANSLUCENT
gravity = Gravity.TOP or Gravity.START
}
@SuppressLint("ClickableViewAccessibility")
private fun createWindowView() {
val root = FrameLayout(context).apply {
clipChildren = false
clipToPadding = false
}
// --- TextureView 用于视频渲染 ---
val tv = TextureView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
// 触摸事件:通过 TouchEventHandler 注入到远程设备
setOnTouchListener { _, event ->
touchEventHandler?.handleMotionEvent(event) == true
}
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(
surface: SurfaceTexture,
width: Int,
height: Int,
) {
touchAreaSize = IntSize(width, height)
val s = android.view.Surface(surface)
currentSurface = s
scope.launch {
NativeCoreFacade.attachVideoSurface(s)
}
Log.i(TAG, "surfaceTextureAvailable: ${width}x${height}")
}
override fun onSurfaceTextureSizeChanged(
surface: SurfaceTexture,
width: Int,
height: Int,
) {
touchAreaSize = IntSize(width, height)
// 更新 TouchEventHandler 的触摸区域
val sess = AppRuntime.scrcpy?.currentSessionState?.value
if (sess != null) {
touchEventHandler = buildTouchEventHandler(
sess,
IntSize(width, height),
)
}
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
currentSurface?.let { s ->
scope.launch {
NativeCoreFacade.detachVideoSurface(s)
}
}
currentSurface = null
return true
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}
}
}
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 {
shape = GradientDrawable.OVAL
setColor(0x80000000.toInt())
setStroke(dp(1), 0x80FFFFFF.toInt())
}
val circle = View(context).apply {
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize).apply {
gravity = Gravity.TOP or Gravity.END
topMargin = dp(8)
rightMargin = dp(8)
}
background = circleBg
setOnTouchListener { v, event ->
handleCircleTouch(v, event, root)
}
}
controlCircle = circle
root.addView(circle)
// --- 控制面板(关闭/全屏按钮)---
val panel = LinearLayout(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
).apply {
gravity = Gravity.TOP or Gravity.END
topMargin = dp(8) + circleSize + dp(8)
rightMargin = dp(8)
}
orientation = LinearLayout.VERTICAL
visibility = View.GONE
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8).toFloat()
setColor(0xCC222222.toInt())
}
}
fun createPanelButton(label: String, onClick: () -> Unit): TextView {
return TextView(context).apply {
text = label
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
gravity = Gravity.CENTER
setPadding(dp(16), dp(10), dp(16), dp(10))
setOnClickListener { onClick() }
}
}
closeBtn = createPanelButton("关闭") {
hide()
onCloseCallback?.invoke()
}
panel.addView(closeBtn)
// 分隔线
panel.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(1),
)
setBackgroundColor(0x40FFFFFF.toInt())
})
fullscreenBtn = createPanelButton("全屏") {
hide()
onFullscreenCallback?.invoke()
}
panel.addView(fullscreenBtn)
controlPanel = panel
root.addView(panel)
windowView = root
}
private fun handleCircleTouch(v: View, event: MotionEvent, root: View): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
dragStartX = event.rawX
dragStartY = event.rawY
windowStartX = windowParams.x
windowStartY = windowParams.y
isDragging = false
return true
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - dragStartX
val dy = event.rawY - dragStartY
if (kotlin.math.abs(dx) > dp(10).toFloat() || kotlin.math.abs(dy) > dp(10).toFloat()) {
isDragging = true
windowParams.x = (windowStartX + dx).toInt()
windowParams.y = (windowStartY + dy).toInt()
windowView?.let { windowManager.updateViewLayout(it, windowParams) }
}
return true
}
MotionEvent.ACTION_UP -> {
if (!isDragging) {
toggleControls()
}
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
panel.visibility = if (controlsVisible) View.VISIBLE else View.GONE
if (controlsVisible) {
// 自动隐藏
scope.launch {
delay(CONTROL_AUTO_HIDE_MS)
if (controlsVisible) {
panel.visibility = View.GONE
controlsVisible = false
}
}
}
}
private fun setupTouchHandler() {
val scrcpy = AppRuntime.scrcpy ?: return
val session = scrcpy.currentSessionState.value ?: return
// 监听 session 变化以重建 TouchEventHandler
sessionCollectJob = scope.launch {
scrcpy.currentSessionState.collectLatest { sess ->
if (sess != null) {
touchEventHandler = buildTouchEventHandler(sess, touchAreaSize)
} else {
touchEventHandler = null
// session 结束,关闭悬浮窗
hide()
onCloseCallback?.invoke()
}
}
}
}
/**
* 构建 TouchEventHandler 实例。
* 与 FullscreenControlPage 中的创建方式一致,使用相同的状态持有者类型。
*/
private fun buildTouchEventHandler(
session: Scrcpy.Session.SessionInfo,
areaSize: IntSize,
): TouchEventHandler {
// 清除旧的触摸状态
resetTouchState()
return TouchEventHandler(
coroutineScope = scope,
session = session,
touchAreaSize = areaSize,
activePointerIds = activePointerIds,
activePointerPositions = activePointerPositions,
activePointerDevicePositions = activePointerDevicePositions,
pointerLabels = pointerLabels,
nextPointerLabel = nextPointerLabel,
mouseHoverEnabled = session.mouseHover,
onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
withContext(Dispatchers.IO) {
AppRuntime.scrcpy?.injectTouch(
action = action,
pointerId = pointerId,
x = x,
y = y,
screenWidth = session.width,
screenHeight = session.height,
pressure = pressure,
actionButton = actionButton,
buttons = buttons,
)
}
},
onBackOrScreenOn = { action ->
withContext(Dispatchers.IO) {
AppRuntime.scrcpy?.pressBackOrTurnScreenOn(action)
}
},
onActiveTouchCountChanged = {},
onActiveTouchDebugChanged = {},
onNextPointerLabelChanged = { nextPointerLabel = it },
)
}
private fun resetTouchState() {
activePointerIds.clear()
activePointerPositions.clear()
activePointerDevicePositions.clear()
pointerLabels.clear()
nextPointerLabel = 1
}
private fun dp(value: Int): Int {
return (value * metrics.density + 0.5f).toInt()
}
}

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

@@ -7,9 +7,11 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.user.home=C\:\\Users\\zhimin.liu\\.gradle
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit # This option should only be used with decoupled projects. For more details, visit
# 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

View File

@@ -1,8 +1,8 @@
[versions] [versions]
agp = "9.2.1" agp = "9.2.1"
bcpkixJdk18on = "1.84" bcpkixJdk18on = "1.85"
boringssl = "20251124" boringssl = "20251124"
conscryptAndroid = "2.5.3" conscryptAndroid = "2.6.1"
datastorePreferences = "1.2.1" datastorePreferences = "1.2.1"
kotlin = "2.4.0" kotlin = "2.4.0"
kotlinx-serialization = "1.11.0" kotlinx-serialization = "1.11.0"

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip distributionUrl=https://mirrors.cloud.tencent.com/gradle/gradle-9.6.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500