Compare commits

..

25 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
5a1a30ad83 fix(widgets):fix cannot input params issue. 2026-07-14 21:10:35 +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
4e07b71ee8 feat():build in local Android and run in honor pad. 2026-07-13 18:08:17 +08:00
謬紗特
d1e9b8cfdc upstream: scrcpy v4.1 (#59)
编译期下载 URL/版本/SHA256 更新至 v4.1
支持 VP8 与 VP9 解码
支持 ignoreVideoEncoderConstraints 参数
AnnexBDecoder.kt 重命名至 MediaCodecVideoDecoder.kt,避免在使用 vp8/vp9 时的语义问题
2026-07-13 13:53:46 +08:00
Miuzarte
d1e20c2a6b chore(deps): ignore miuix in renovatebot 2026-07-13 12:29:05 +08:00
Miuzarte
a2937f5fe1 chore: code formatting 2026-07-12 21:22:18 +08:00
Miuzarte
b918f18e9e fix: resolve MTK decoder crash on unsupported resolution and rotation
- 新增 DecoderCapabilities,创建 decoder 前预检本机解码器分辨率支持
2026-07-12 19:00:52 +08:00
Miuzarte
90a188f355 refactor: break down NativeCoreFacade
- 捕获解码器错误
- 旋转屏幕重建不复用旧 buffer
2026-07-12 18:40:54 +08:00
謬紗特
d459fae571 Merge pull request #51 from EX3124/main
fix: Status bar color when the application is opened
2026-07-10 13:09:50 +08:00
EX3124
30d6cb5191 fix: Status bar color when the application is opened 2026-07-10 12:52:09 +08:00
Miuzarte
14c95cd734 fix: make basicPip lazy to avoid NPE
close #50
2026-07-09 10:28:15 +08:00
Miuzarte
0fa3a3d544 feat: add BreadcrumbBar for dir path 2026-07-08 19:14:00 +08:00
49 changed files with 2680 additions and 527 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-*

2
.gitignore vendored
View File

@@ -47,3 +47,5 @@ abandoned_assets/
# scrcpy-server binary (downloaded by Gradle during build) # scrcpy-server binary (downloaded by Gradle during build)
app/src/main/assets/bin/scrcpy-server-v* app/src/main/assets/bin/scrcpy-server-v*
docs/

View File

@@ -1,9 +1,23 @@
# Change Log # Change Log
## 0.5.0
- 依赖: scrcpy 升级至 v4.1
- 新增: vp8/vp9 解码
## 0.4.5
- 重构: 改了好多不知道怎么写,总之修复了一些已知问题
## 0.4.4
- 修复: 画中画初始化空指针
## 0.4.3 ## 0.4.3
- 改进: 对控制通道增加流控选项 - 改进: 对控制通道增加流控选项
- 新增: 预发行版更新检测 - 新增: 预发行版更新检测
- 改进: 文件页的路径改用新实现的面包屑
## 0.4.2 ## 0.4.2

View File

@@ -29,7 +29,7 @@
<img src="https://github.com/user-attachments/assets/f1271cc7-ce45-46d2-8a01-0f4a367c0e4a" height="300" alt="Streaming" /> <img src="https://github.com/user-attachments/assets/f1271cc7-ce45-46d2-8a01-0f4a367c0e4a" height="300" alt="Streaming" />
<img src="https://github.com/user-attachments/assets/220fc973-f3b7-4bec-8733-95bbddf95a7a" height="300" alt="Scrcpy All Options" /> <img src="https://github.com/user-attachments/assets/220fc973-f3b7-4bec-8733-95bbddf95a7a" height="300" alt="Scrcpy All Options" />
<img src="https://github.com/user-attachments/assets/6d4cff1b-1277-44e4-bcfa-18201738a703" height="300" alt="Terminal" /> <img src="https://github.com/user-attachments/assets/6d4cff1b-1277-44e4-bcfa-18201738a703" height="300" alt="Terminal" />
<img src="https://github.com/user-attachments/assets/886ba6db-d6a7-4528-96fb-bb0ce4deb953" height="300" alt="Files" /> <img src="https://github.com/user-attachments/assets/3e4edc6c-ddfc-478b-aa7d-aa797a9f0e74" height="300" alt="Files" />
<img src="https://github.com/user-attachments/assets/d3b29861-6e86-4301-9b1d-1f7836ad9b7e" height="300" alt="Settings" /> <img src="https://github.com/user-attachments/assets/d3b29861-6e86-4301-9b1d-1f7836ad9b7e" height="300" alt="Settings" />
<img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Multi Touch Test" /> <img src="https://github.com/user-attachments/assets/f513b7ba-0389-4176-8382-c1a08c4eba99" height="300" alt="Multi Touch Test" />
<img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Virtual Buttons Reorder" /> <img src="https://github.com/user-attachments/assets/7a50bd1f-8095-4269-8e58-88316d86e3d8" height="300" alt="Virtual Buttons Reorder" />

View File

@@ -59,8 +59,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 33 versionCode = 36
versionName = "0.4.3_pre1" versionName = "0.5.0"
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -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")
@@ -168,9 +179,9 @@ dependencies {
} }
val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin" val scrcpyServerAssetDir = "${project.projectDir}/src/main/assets/bin"
val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.0" val scrcpyServerAssetFile = "$scrcpyServerAssetDir/scrcpy-server-v4.1"
val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.0/scrcpy-server-v4.0" val scrcpyServerDownloadUrl = "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-server-v4.1"
val scrcpyServerSha256 = "84924bd564a1eb6089c872c7521f968058977f91f5ff02514a8c74aff3210f3a" val scrcpyServerSha256 = "deacb991ed2509715160ffdc7907e47b4160eb30d1566217e9047fd5b8850cae"
val downloadScrcpyServer by tasks.registering { val downloadScrcpyServer by tasks.registering {
description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch" description = "Download scrcpy-server binary from GitHub releases if absent or SHA256 mismatch"
@@ -203,7 +214,7 @@ val downloadScrcpyServer by tasks.registering {
val needsDownload = !file.exists() || computeSha256(file) != expectedSha val needsDownload = !file.exists() || computeSha256(file) != expectedSha
if (needsDownload) { if (needsDownload) {
logger.lifecycle("Downloading scrcpy-server-v4.0 from GitHub releases...") logger.lifecycle("Downloading scrcpy-server-v4.1 from GitHub releases...")
try { try {
URI(url).toURL().openStream().use { input -> URI(url).toURL().openStream().use { input ->
file.outputStream().use { output -> file.outputStream().use { output ->
@@ -212,7 +223,7 @@ val downloadScrcpyServer by tasks.registering {
} }
} catch (e: Exception) { } catch (e: Exception) {
throw GradleException( throw GradleException(
"Failed to download scrcpy-server-v4.0 from GitHub releases.\n" + "Failed to download scrcpy-server-v4.1 from GitHub releases.\n" +
" URL: $url\n" + " URL: $url\n" +
" You may download it manually and place it at: ${file.absolutePath}\n" + " You may download it manually and place it at: ${file.absolutePath}\n" +
" If you are behind a proxy, check your Gradle proxy settings\n" + " If you are behind a proxy, check your Gradle proxy settings\n" +
@@ -223,14 +234,14 @@ val downloadScrcpyServer by tasks.registering {
val actualSha = computeSha256(file) val actualSha = computeSha256(file)
require(actualSha == expectedSha) { require(actualSha == expectedSha) {
"SHA256 mismatch for scrcpy-server-v4.0!\n" + "SHA256 mismatch for scrcpy-server-v4.1!\n" +
" Expected: $expectedSha\n" + " Expected: $expectedSha\n" +
" Got: $actualSha\n" + " Got: $actualSha\n" +
" Delete ${file.absolutePath} to retry download." " Delete ${file.absolutePath} to retry download."
} }
logger.lifecycle("scrcpy-server-v4.0 downloaded and verified.") logger.lifecycle("scrcpy-server-v4.1 downloaded and verified.")
} else { } else {
logger.lifecycle("scrcpy-server-v4.0 exists with correct SHA256, skip download.") logger.lifecycle("scrcpy-server-v4.1 exists with correct SHA256, skip download.")
} }
} }
} }

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,9 +85,28 @@ class MainActivity: FragmentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
val launchParams = AdbLaunchParams.fromIntent(intent)
setContent { setContent {
MaterialTheme {
if (launchParams != null) {
AppRuntime.adbLaunchParams = launchParams
}
var showMainScreen by remember { mutableStateOf(launchParams != null) }
if (showMainScreen) {
MainScreen() MainScreen()
} }
if (!showMainScreen) {
QuickConnectDialog(
onConnect = { host, port -> doConnect(host, port) },
onMoreSettings = { showMainScreen = true },
)
}
}
}
} }
override fun onResume() { override fun onResume() {
@@ -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,58 +1,68 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.DecoderCapabilities
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
import io.github.miuzarte.scrcpyforandroid.nativecore.VideoDecoderController
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.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque
import java.util.concurrent.CopyOnWriteArraySet
/** /**
* Facade that centralizes video rendering. * Facade that centralizes video rendering and decoder management.
* *
* Provides helpers for: * Acts as a thin front-end over [VideoDecoderController] (decoder lifecycle, bootstrap
* - Surface/Decoder management for video rendering * cache, error detection) and [PersistentVideoRenderer] (EGL / surface management).
* - Video size and FPS monitoring *
* The facade owns the session lifecycle mutex and coordinates surface attach/detach
* with decoder creation. It also publishes video size / FPS to listeners.
*
* Error recovery:
* - When the local decoder cannot handle the video resolution (e.g. MTK AVC caps at
* 2048 but scrcpy sends 2400), the facade can automatically restart the session with
* a lower `max_size` (controlled by the "downsize on decode error" app setting).
* - When the decoder enters an unrecoverable error state at runtime (e.g. MTK OMX
* buffer conflict after rotation), the facade restarts the session.
*/ */
object NativeCoreFacade { object NativeCoreFacade {
private val sessionLifecycleMutex = Mutex() private val sessionLifecycleMutex = Mutex()
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val renderer = PersistentVideoRenderer() private val renderer = PersistentVideoRenderer()
private val controller = VideoDecoderController(renderer)
@Volatile
private var activeSurfaceId: Int? = null private var activeSurfaceId: Int? = null
private var decoder: AnnexBDecoder? = null
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
private val mainHandler = Handler(Looper.getMainLooper())
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
@Volatile @Volatile
private var recordingSurfaceAttached = false private var recordingSurfaceAttached = false
@Volatile
private var latestConfigPacket: CachedPacket? = null
private var packetCount: Long = 0
@Volatile
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
// Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted) // Reference to Scrcpy for reading currentSessionState (set by onScrcpySessionStarted)
@Volatile @Volatile
private var scrcpyRef: Scrcpy? = null private var scrcpyRef: Scrcpy? = null
@Volatile
private var packetCount: Long = 0
// Cached ClientOptions for session restart (downgrade / error recovery)
@Volatile
private var cachedClientOptions: ClientOptions? = null
// Guards against recursive restarts (restart triggers onScrcpySessionStopped/Started)
@Volatile
private var isRestarting = false
suspend fun close() { suspend fun close() {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
releaseAllDecoders() controller.releaseAll()
renderer.release() renderer.release()
} }
} }
@@ -71,30 +81,20 @@ object NativeCoreFacade {
return return
} }
val newId = System.identityHashCode(surface) val newId = System.identityHashCode(surface)
if (activeSurfaceId == newId && decoder != null) { if (activeSurfaceId == newId && controller.isDecoderUsable()) {
return return
} }
Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId") Log.i(TAG, "attachVideoSurface(): surfaceId=$newId oldSurfaceId=$activeSurfaceId")
activeSurfaceId = newId activeSurfaceId = newId
renderer.attachDisplaySurface(surface) controller.attachDisplaySurface(surface)
val session = currentSessionInfo ?: return val session = controller.getCurrentSessionInfo() ?: return
val currentDecoder = decoder if (controller.isDecoderUsable()) {
if (currentDecoder != null) {
Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface") Log.i(TAG, "attachVideoSurface(): try switch decoder output to persistent surface")
val switched = currentDecoder.switchOutputSurface(renderer.getDecoderSurface()) if (controller.trySwitchDecoderSurface()) {
Log.i(TAG, "attachVideoSurface(): switchOutputSurface success=$switched")
if (switched) {
return return
} }
} }
if (session.width <= 0 || session.height <= 0) { controller.ensureDecoder(session)
Log.i(
TAG,
"attachVideoSurface(): defer decoder, session size not yet known (${session.width}x${session.height})",
)
return
}
createOrReplaceDecoder(session)
} }
} }
@@ -123,12 +123,7 @@ object NativeCoreFacade {
"detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder", "detachVideoSurface(): surfaceId=$requestId releaseDecoder=$releaseDecoder",
) )
activeSurfaceId = null activeSurfaceId = null
renderer.detachDisplaySurface(surface, releaseSurface = false) controller.detachDisplaySurface(surface, releaseDecoder)
if (releaseDecoder) {
Log.i(TAG, "detachVideoSurface(): releasing decoder with destroyed surface")
decoder?.release()
decoder = null
}
} }
} }
@@ -140,10 +135,10 @@ object NativeCoreFacade {
) { ) {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
recordingSurfaceAttached = true recordingSurfaceAttached = true
renderer.attachRecordSurface(surface, width, height, onFrameRendered) controller.attachRecordSurface(surface, width, height, onFrameRendered)
val session = currentSessionInfo val session = controller.getCurrentSessionInfo()
if (session != null && decoder == null) { if (session != null && !controller.isDecoderUsable()) {
createOrReplaceDecoder(session) controller.ensureDecoder(session)
} }
} }
} }
@@ -154,94 +149,78 @@ object NativeCoreFacade {
) { ) {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
recordingSurfaceAttached = false recordingSurfaceAttached = false
renderer.detachRecordSurface(surface, releaseSurface) controller.detachRecordSurface(surface, releaseSurface)
if (activeSurfaceId == null) { if (activeSurfaceId == null) {
decoder?.release() controller.releaseAll()
decoder = null
} }
} }
} }
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener) fun addVideoSizeListener(listener: (Int, Int) -> Unit) = controller.addVideoSizeListener(listener)
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener) fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = controller.removeVideoSizeListener(listener)
fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener) fun addVideoFpsListener(listener: (Float) -> Unit) = controller.addVideoFpsListener(listener)
fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener) fun removeVideoFpsListener(listener: (Float) -> Unit) = controller.removeVideoFpsListener(listener)
/** /**
* Called by Scrcpy.kt when a session starts. * Called by Scrcpy.kt when a session starts.
* Sets up video decoders for registered surfaces. * Sets up video decoders for registered surfaces.
*
* [options] is cached so the facade can restart the session (with a modified max_size)
* when the decoder fails.
*/ */
suspend fun onScrcpySessionStarted( suspend fun onScrcpySessionStarted(
session: Scrcpy.Session.SessionInfo, session: Scrcpy.Session.SessionInfo,
sessionMgr: Scrcpy.Session, sessionMgr: Scrcpy.Session,
scrcpy: Scrcpy, scrcpy: Scrcpy,
options: ClientOptions,
) = sessionLifecycleMutex.withLock { ) = sessionLifecycleMutex.withLock {
scrcpyRef = scrcpy scrcpyRef = scrcpy
currentSessionInfo = session cachedClientOptions = options
releaseAllDecoders() isRestarting = false
synchronized(bootstrapLock) { controller.releaseAll()
bootstrapPackets.clear() controller.resetBootstrap()
latestConfigPacket = null
}
if (activeSurfaceId != null || recordingSurfaceAttached) { if (activeSurfaceId != null || recordingSurfaceAttached) {
// v4.0: width/height come from first video session packet, not from initial metadata // v4.0: width/height come from first video session packet, not from initial metadata
if (session.width > 0 && session.height > 0) { if (session.width > 0 && session.height > 0) {
Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface") Log.i(TAG, "onScrcpySessionStarted(): bind decoder to persistent surface")
createOrReplaceDecoder(session) controller.ensureDecoder(session)
} else { } else {
Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)") Log.i(TAG, "onScrcpySessionStarted(): defer decoder until first video session packet (v4.0)")
} }
} }
packetCount = 0 packetCount = 0
sessionMgr.attachVideoConsumer { packet -> sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet) cacheAndFeed(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}",
)
}
val dec = decoder ?: return@attachVideoConsumer
runCatching {
dec.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig,
)
}
} }
} }
/** /**
* Called by Scrcpy when a video session packet arrives with new dimensions. * Called by Scrcpy when a video session packet arrives with new dimensions.
* Launches a coroutine to create or rebuild the decoder under the lifecycle mutex. * Launches a coroutine to create or rebuild the decoder under the lifecycle mutex.
*
* Before creating the decoder, checks whether the local hardware decoder supports the
* target resolution. If not, triggers a downgrade restart (when enabled) or shows an
* error snackbar.
*/ */
fun onVideoSizeChanged(width: Int, height: Int) { fun onVideoSizeChanged(width: Int, height: Int) {
lifecycleScope.launch { lifecycleScope.launch {
sessionLifecycleMutex.withLock { sessionLifecycleMutex.withLock {
if (isRestarting) return@withLock
val scrcpy = scrcpyRef ?: return@withLock val scrcpy = scrcpyRef ?: return@withLock
val info = scrcpy.currentSessionState.value ?: return@withLock val info = scrcpy.currentSessionState.value ?: return@withLock
if (info.width <= 0 || info.height <= 0) return@withLock if (info.width <= 0 || info.height <= 0) return@withLock
if (decoder == null) { val mime = info.codec?.mime ?: "video/avc"
// Initial creation (v4.0: deferred until first session packet) if (!DecoderCapabilities.isSizeSupported(mime, info.width, info.height)) {
Log.i(TAG, "onVideoSizeChanged(): create decoder ${info.width}x${info.height}") Log.w(
currentSessionInfo = info
createOrReplaceDecoder(info)
} else if (currentSessionInfo != null &&
(info.width != currentSessionInfo!!.width || info.height != currentSessionInfo!!.height)
) {
// Flex display: rebuild decoder on size change
Log.i(
TAG, TAG,
"onVideoSizeChanged(): rebuild decoder ${currentSessionInfo!!.width}x${currentSessionInfo!!.height} ${info.width}x${info.height}", "onVideoSizeChanged(): decoder does not support ${info.width}x${info.height} for $mime",
) )
currentSessionInfo = info handleUnsupportedSize(info.width, info.height, mime)
createOrReplaceDecoder(info) return@withLock
} }
controller.rebuildDecoderForSize(info)
} }
} }
} }
@@ -251,150 +230,98 @@ object NativeCoreFacade {
* Cleans up decoders and resets state. * Cleans up decoders and resets state.
*/ */
suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock { suspend fun onScrcpySessionStopped() = sessionLifecycleMutex.withLock {
releaseAllDecoders() controller.releaseAll()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
scrcpyRef = null scrcpyRef = null
currentSessionInfo = null
recordingSurfaceAttached = false recordingSurfaceAttached = false
} }
private const val TAG = "NativeCoreFacade" private fun cacheAndFeed(packet: Scrcpy.Session.VideoPacket) {
private const val MAX_BOOTSTRAP_PACKETS = 90 packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
private data class CachedPacket( Log.i(
val data: ByteArray, TAG,
val ptsUs: Long, "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} usable=${controller.isDecoderUsable()}",
val isConfig: Boolean, )
val isKeyFrame: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CachedPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (isKeyFrame != other.isKeyFrame) return false
if (!data.contentEquals(other.data)) return false
return true
} }
val usable = controller.feed(packet)
override fun hashCode(): Int { if (!usable && !isRestarting) {
var result = ptsUs.hashCode() Log.e(TAG, "videoFeed(): decoder became unusable after feed, packets=$packetCount")
result = 31 * result + isConfig.hashCode() handleDecoderError()
result = 31 * result + isKeyFrame.hashCode()
result = 31 * result + data.contentHashCode()
return result
} }
} }
/** /**
* Create or replace the active decoder bound to [surface] for [session]. * Handle the case where the local decoder does not support the target resolution.
* *
* - Chooses MIME type from `session.codec` and constructs an [AnnexBDecoder]. * When "downsize on decode error" is enabled (default), restarts the session with a
* - The decoder's `onOutputSizeChanged` callback publishes size changes to * lower `max_size` derived from [DecoderCapabilities.maxSupportedSize]. Otherwise,
* registered listeners on the main thread. * shows an error snackbar and leaves the decoder uncreated (black screen, no crash).
* - Newly created decoders are fed with any cached bootstrap packets to allow
* faster playback startup.
*/ */
private fun createOrReplaceDecoder(session: Scrcpy.Session.SessionInfo) { private suspend fun handleUnsupportedSize(width: Int, height: Int, mime: String) {
val surface = renderer.getDecoderSurface() val downsizeEnabled = Storage.appSettings.bundleState.value.downsizeOnDecodeError
decoder?.release() if (!downsizeEnabled) {
decoder = null AppRuntime.snackbar(R.string.vm_decoder_init_failed, "${width}x${height}")
Log.i(
TAG,
"createOrReplaceDecoder(): " +
"codec=${session.codec?.string ?: "null"}, " +
"size=${session.width}x${session.height}, " +
"persistent=true",
)
val newDecoder = AnnexBDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = when (session.codec) {
Codec.H264 -> "video/avc"
Codec.H265 -> "video/hevc"
Codec.AV1 -> "video/av01"
else -> "video/avc"
},
onOutputSizeChanged = { width, height ->
val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) {
return@AnnexBDecoder
}
Log.i(
TAG,
"videoSizeChanged(): ${current.width}x${current.height} -> ${width}x${height}",
)
currentSessionInfo = current.copy(width = width, height = height)
mainHandler.post {
videoSizeListeners.forEach { listener ->
runCatching { listener(width, height) }
}
}
},
onFpsUpdated = { fps ->
mainHandler.post {
videoFpsListeners.forEach { listener ->
runCatching { listener(fps) }
}
}
},
)
decoder = newDecoder
replayBootstrapPackets(newDecoder)
}
private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
if (snapshot.isEmpty()) {
return return
} }
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}") val maxSize = DecoderCapabilities.maxSupportedSize(mime)
snapshot.forEach { packet -> Log.i(TAG, "handleUnsupportedSize(): downgrading to max_size=$maxSize")
AppRuntime.snackbar(R.string.vm_decoder_unsupported_size, width, height, maxSize)
downgradeAndRestart(maxSize)
}
/**
* Handle a runtime decoder error (codec entered unrecoverable state).
*
* Restarts the scrcpy session so a fresh decoder is created from scratch.
*/
private fun handleDecoderError() {
if (isRestarting) return
isRestarting = true
Log.i(TAG, "handleDecoderError(): restarting session due to decoder error")
AppRuntime.snackbar(R.string.vm_decoder_error_restarting)
lifecycleScope.launch {
sessionLifecycleMutex.withLock {
restartSessionWith(cachedClientOptions)
}
}
}
/**
* Restart the scrcpy session with a modified `max_size`.
*
* Persists the new max_size to [Storage.scrcpyOptions] so the user sees the change
* in the UI, then stops and restarts the session.
*/
private suspend fun downgradeAndRestart(maxSize: Int) {
if (isRestarting) return
isRestarting = true
val options = cachedClientOptions ?: return
val newOptions = options.copy(maxSize = maxSize.toUShort()).fix().validate()
// Persist the new max_size so the UI reflects the downgrade.
runCatching { runCatching {
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig) val soBundle = Storage.scrcpyOptions.bundleState.value
} Storage.scrcpyOptions.saveBundle(soBundle.copy(maxSize = maxSize))
}
} }
private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) { restartSessionWith(newOptions)
val cached = CachedPacket(
data = packet.data.copyOf(),
ptsUs = packet.ptsUs,
isConfig = packet.isConfig,
isKeyFrame = packet.isKeyFrame,
)
synchronized(bootstrapLock) {
if (cached.isConfig) {
latestConfigPacket = cached
bootstrapPackets.clear()
bootstrapPackets.addLast(cached)
return
} }
if (cached.isKeyFrame) { /**
bootstrapPackets.clear() * Stop the current session and start a new one with [options].
latestConfigPacket?.let { bootstrapPackets.addLast(it) } *
bootstrapPackets.addLast(cached) * Must be called while holding [sessionLifecycleMutex]. The [isRestarting] flag
return * prevents recursive restarts — it is cleared in [onScrcpySessionStarted] when the
* new session comes up.
*/
private suspend fun restartSessionWith(options: ClientOptions?) {
val scrcpy = scrcpyRef ?: return
if (options == null) return
Log.i(TAG, "restartSessionWith(): stopping current session")
runCatching { scrcpy.stop(Scrcpy.StopReason.USER) }
Log.i(TAG, "restartSessionWith(): starting new session with max_size=${options.maxSize}")
runCatching { scrcpy.start(options) }
} }
while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) { private const val TAG = "NativeCoreFacade"
bootstrapPackets.removeFirst()
}
bootstrapPackets.addLast(cached)
}
}
private fun releaseAllDecoders() {
runCatching { decoder?.release() }
decoder = null
}
} }

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 = 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 {
if (isQuickLaunch) {
QuickLaunchScreen(activity = this)
} else {
StreamScreen(activity = this) 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 -> {}
} }
} }
override fun onStart() {
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))
}
},
) )
*/
} }
}
fun configurePip(block: Builder.() -> Builder) =
basicPip.setPictureInPictureParams(Builder().block().build())
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

@@ -140,6 +140,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>): List<DeviceShortcut> b
val p = port ?: old.port val p = port ?: old.port
listOf("$host:$p") listOf("$host:$p")
} }
updateById -> old.addresses updateById -> old.addresses
newPort != null -> { newPort != null -> {

View File

@@ -0,0 +1,92 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.util.Log
/**
* Queries the local device's hardware decoder capabilities to determine whether a given
* video size is supported before attempting to create a [MediaCodec].
*
* This is primarily needed for chips like MTK AC8257 whose AVC decoder caps at 2048x1088,
* while scrcpy may send 1080x2400 (long edge 2400 > 2048), causing
* [MediaCodec.configure] to throw [IllegalArgumentException].
*/
object DecoderCapabilities {
private const val TAG = "DecoderCapabilities"
/**
* The conservative fallback max size returned by [maxSupportedSize] when the codec list
* query fails or returns no usable information. 1920 is a safe upper bound for virtually
* all hardware decoders on Android 10+.
*/
private const val FALLBACK_MAX_SIZE = 1920
/**
* Check whether any hardware decoder on this device supports decoding [width]x[height]
* for the given [mime] type.
*
* - Only decoders (not encoders) are considered.
* - Software-only decoders are included as a last resort (some devices only have a
* software decoder for AV1), but hardware decoders are preferred.
* - When [MediaCodecInfo.VideoCapabilities.areSizeSupported] is unavailable or throws,
* returns true (optimistic) so the caller can still attempt creation and rely on
* [DecoderException] for the error path.
*/
fun isSizeSupported(mime: String, width: Int, height: Int): Boolean {
if (width <= 0 || height <= 0) return true
return try {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
codecList.codecInfos.any { info ->
info.isEncoder.not() &&
isCodecCapable(info, mime, width, height)
}
} catch (e: Exception) {
Log.w(TAG, "isSizeSupported(): query failed for $mime ${width}x$height, assuming supported", e)
true
}
}
/**
* Returns the largest "long edge" size (i.e. `max(width, height)`) that any decoder on
* this device supports for [mime].
*
* Used by the downgrade logic to pick a new `max_size` value that the decoder can handle.
* The probe tries common sizes from 1080 up to 4096; if none are supported (unlikely),
* returns [FALLBACK_MAX_SIZE].
*/
fun maxSupportedSize(mime: String): Int {
return try {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val decoders = codecList.codecInfos.filter { !it.isEncoder }
// Try from largest to smallest; return the first that works for any decoder.
// This avoids probing every integer — a handful of common breakpoints is enough.
for (size in intArrayOf(3840, 2560, 2048, 1920, 1600, 1280, 1080)) {
if (decoders.any { isCodecCapable(it, mime, size, size) }) {
return size
}
}
FALLBACK_MAX_SIZE
} catch (e: Exception) {
Log.w(TAG, "maxSupportedSize(): query failed for $mime, returning fallback $FALLBACK_MAX_SIZE", e)
FALLBACK_MAX_SIZE
}
}
private fun isCodecCapable(
info: MediaCodecInfo,
mime: String,
width: Int,
height: Int,
): Boolean {
return try {
val caps = info.getCapabilitiesForType(mime) ?: return false
caps.videoCapabilities?.isSizeSupported(width, height) == true
} catch (e: Exception) {
// Some codecs throw for unsupported mime types — that's fine, just skip.
false
}
}
}

View File

@@ -0,0 +1,17 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
/**
* Thrown when a [MediaCodec] decoder fails to initialize or enters an unrecoverable error state.
*
* Carries the MIME type and target dimensions so callers (e.g. [VideoDecoderController])
* can decide whether to downgrade the video size and restart the session.
*/
class DecoderException(
val mime: String,
val width: Int,
val height: Int,
cause: Throwable? = null,
): RuntimeException(
"Decoder failed for mime=$mime size=${width}x${height}",
cause,
)

View File

@@ -308,8 +308,7 @@ internal object DirectAdbTransport {
private fun generatePkcs8PrivateKey(der: ByteArray): PrivateKey { private fun generatePkcs8PrivateKey(der: ByteArray): PrivateKey {
val kf = KeyFactory.getInstance("RSA") val kf = KeyFactory.getInstance("RSA")
val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull() val key = runCatching { kf.generatePrivate(PKCS8EncodedKeySpec(der)) }.getOrNull()
return if (key is RSAPrivateCrtKey) key return key as? RSAPrivateCrtKey ?: kf.generatePrivate(parsePkcs8PrivateKey(der))
else kf.generatePrivate(parsePkcs8PrivateKey(der))
} }
private fun parsePublicKey(content: String): PublicKey { private fun parsePublicKey(content: String): PublicKey {
@@ -366,7 +365,25 @@ internal object DirectAdbTransport {
private data class PemBlock( private data class PemBlock(
val label: String, val label: String,
val bytes: ByteArray, val bytes: ByteArray,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PemBlock
if (label != other.label) return false
if (!bytes.contentEquals(other.bytes)) return false
return true
}
override fun hashCode(): Int {
var result = label.hashCode()
result = 31 * result + bytes.contentHashCode()
return result
}
}
private fun readPem(content: String): PemBlock? { private fun readPem(content: String): PemBlock? {
val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null val begin = Regex("-----BEGIN ([A-Z0-9 ]+)-----").find(content) ?: return null
@@ -661,9 +678,13 @@ internal class DirectAdbConnection(
fun openStream(service: String): AdbSocketStream { fun openStream(service: String): AdbSocketStream {
val localId = nextLocalId.getAndIncrement() val localId = nextLocalId.getAndIncrement()
val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow val flowControlWindow = Storage.appSettings.bundleState.value.adbFlowControlWindow
val stream = AdbSocketStream(localId, { command, arg0, arg1, data -> val stream = AdbSocketStream(
localId,
{ command, arg0, arg1, data ->
sendMsg(command, arg0, arg1, data) sendMsg(command, arg0, arg1, data)
}, flowControlWindow) },
flowControlWindow,
)
streams[localId] = stream streams[localId] = stream
sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8)) sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
try { try {
@@ -979,8 +1000,9 @@ class AdbSocketStream(
private val queue = LinkedBlockingQueue<Any>() private val queue = LinkedBlockingQueue<Any>()
// need notifyAll() / wait() // need notifyAll() / wait()
private val writeLock = java.lang.Object() private val writeLock = Object()
@Volatile private var inflightWrites = 0 @Volatile
private var inflightWrites = 0
private object EndOfStreamMarker private object EndOfStreamMarker

View File

@@ -6,19 +6,21 @@ import android.util.Log
import android.view.Surface import android.view.Surface
/** /**
* AnnexBDecoder * MediaCodecVideoDecoder
* *
* Purpose: * Purpose:
* - Wraps Android MediaCodec for Annex-B framed codecs (H.264/H.265/AV1). * - Wraps Android MediaCodec for all video codecs (H.264/H.265/AV1/VP8/VP9).
* - Handles critical startup packets (config/keyframes) and provides callbacks * - Handles critical startup packets (config/keyframes) and provides callbacks
* for output size changes and FPS updates. * for output size changes and FPS updates.
* - Codecs without Annex-B config packets (VP8/VP9) work natively: no CSD is
* required for MediaCodec initialization.
* *
* Threading / safety: * Threading / safety:
* - Public methods are synchronized to allow calls from multiple threads * - Public methods are synchronized to allow calls from multiple threads
* (packet producer vs. teardown). Internally, MediaCodec callbacks and * (packet producer vs. teardown). Internally, MediaCodec callbacks and
* buffer queues are used on the calling thread. * buffer queues are used on the calling thread.
*/ */
class AnnexBDecoder( class MediaCodecVideoDecoder(
width: Int, width: Int,
height: Int, height: Int,
outputSurface: Surface, outputSurface: Surface,
@@ -41,9 +43,23 @@ class AnnexBDecoder(
@Volatile @Volatile
private var released = false private var released = false
/**
* Set when the codec reports an unrecoverable error (either during configure/start
* or at runtime via [MediaCodec.CodecException] / [IllegalStateException]).
*
* Once non-null, [isUsable] returns false and subsequent [feedAnnexB] calls are no-ops.
* The controller ([VideoDecoderController]) checks this to trigger session restart.
*/
@Volatile
internal var codecError: Throwable? = null
private set
init { init {
if (!outputSurface.isValid) { if (!outputSurface.isValid) {
throw IllegalStateException("Cannot initialize decoder: output surface is not valid") throw DecoderException(
mimeType, width, height,
IllegalStateException("Output surface is not valid"),
)
} }
val format = MediaFormat.createVideoFormat(mimeType, width, height) val format = MediaFormat.createVideoFormat(mimeType, width, height)
if (sps != null) { if (sps != null) {
@@ -52,8 +68,14 @@ class AnnexBDecoder(
if (pps != null) { if (pps != null) {
format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps)) format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
} }
try {
codec.configure(format, outputSurface, null, 0) codec.configure(format, outputSurface, null, 0)
codec.start() codec.start()
} catch (e: Exception) {
runCatching { codec.release() }
codecError = e
throw DecoderException(mimeType, width, height, e)
}
} }
/** /**
@@ -68,7 +90,7 @@ class AnnexBDecoder(
*/ */
@Synchronized @Synchronized
fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) { fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
if (released) { if (released || codecError != null) {
return return
} }
runCatching { runCatching {
@@ -107,9 +129,13 @@ class AnnexBDecoder(
} }
drainOutput() drainOutput()
}.onFailure { }.onFailure {
Log.w( // MediaCodec.CodecException and IllegalStateException indicate the codec is in an
// unrecoverable error state (e.g. MTK OMX fatal error after surface conflicts).
// Record the error so isUsable() returns false and the controller can react.
codecError = it
Log.e(
TAG, TAG,
"feed failed: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig", "feed failed (codec marked unusable): mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig",
it, it,
) )
} }
@@ -145,6 +171,12 @@ class AnnexBDecoder(
runCatching { codec.release() } runCatching { codec.release() }
} }
/**
* Returns true when the decoder is still alive and the codec has not reported
* an unrecoverable error.
*/
fun isUsable(): Boolean = !released && codecError == null
private fun drainOutput() { private fun drainOutput() {
while (true) { while (true) {
val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US) val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
@@ -202,7 +234,7 @@ class AnnexBDecoder(
} }
companion object { companion object {
private const val TAG = "AnnexBDecoder" private const val TAG = "MediaCodecVideoDecoder"
private const val MIME_AVC = "video/avc" private const val MIME_AVC = "video/avc"
private const val INPUT_TIMEOUT_US = 10_000L private const val INPUT_TIMEOUT_US = 10_000L
private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L

View File

@@ -62,6 +62,75 @@ class PersistentVideoRenderer {
} }
} }
/**
* Atomically tear down and recreate the decoder-side surface (SurfaceTexture + OES texture
* + Surface), returning a fresh [Surface] for a new MediaCodec to render into.
*
* This is needed because some OMX decoders (notably MTK) fail when a new codec is configured
* against a surface that still has pending buffers from a previously released codec.
* By recreating the underlying SurfaceTexture + texture, the new codec gets a clean buffer
* queue with no leftover state.
*
* - Must be called after the old decoder has been released, so no producer is writing to
* the old surface at the same time.
* - Runs on the renderer HandlerThread (via [handler]) so it is serialized with [drawFrame].
* - EGL context, display/record EGL surfaces, and the shader program are preserved; only
* `oesTextureId` / `decoderSurfaceTexture` / `decoderSurface` are recreated.
*/
fun recreateDecoderSurface(): Surface {
ensureInitialized()
val latch = java.util.concurrent.CountDownLatch(1)
var result: Surface? = null
handler.post {
try {
if (released) {
error("renderer already released")
}
recreateDecoderSurfaceLocked()
result = decoderSurface
} finally {
latch.countDown()
}
}
latch.await()
return result ?: error("failed to recreate decoder surface")
}
private fun recreateDecoderSurfaceLocked() {
Log.i(tag, "recreateDecoderSurfaceLocked(): rebuilding decoder surface")
// Tear down old decoder-side resources. drawFrame() won't run concurrently
// because we're on the same HandlerThread.
runCatching { decoderSurface?.release() }
decoderSurface = null
runCatching { decoderSurfaceTexture?.release() }
decoderSurfaceTexture = null
if (oesTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(oesTextureId), 0)
oesTextureId = 0
}
// Recreate from scratch (mirrors initializeLocked lines 243-259)
oesTextureId = createExternalTexture()
decoderSurfaceTexture = SurfaceTexture(oesTextureId).apply {
setOnFrameAvailableListener(
{
val n = frameAvailableCount.incrementAndGet()
if (n == 1L || n % 120L == 0L) {
Log.i(
tag,
"onFrameAvailable(): available=$n consumed=${frameConsumedCount.get()} rendered=${frameRenderedCount.get()} display=${displaySurfaceId != null}",
)
}
drawFrame()
},
handler,
)
}
decoderSurface = Surface(decoderSurfaceTexture)
Log.i(tag, "recreateDecoderSurfaceLocked(): decoder surface rebuilt, texture=$oesTextureId")
}
fun attachDisplaySurface(surface: Surface) { fun attachDisplaySurface(surface: Surface) {
ensureInitialized() ensureInitialized()
val newId = System.identityHashCode(surface) val newId = System.identityHashCode(surface)

View File

@@ -0,0 +1,417 @@
package io.github.miuzarte.scrcpyforandroid.nativecore
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.util.ArrayDeque
import java.util.concurrent.CopyOnWriteArraySet
/**
* Owns the video [MediaCodecVideoDecoder] lifecycle and all state surrounding it:
*
* - Creating / rebuilding / releasing the decoder bound to the persistent renderer surface.
* - Caching and replaying bootstrap packets (config + keyframe + following frames) so a
* freshly created decoder can resume playback quickly.
* - Detecting decoder errors (both construction-time [DecoderException] and runtime
* codec errors) and exposing them via [isDecoderUsable] / the return value of [feed]
* for the facade to react.
* - Publishing video size / FPS changes to registered listeners on the main thread.
*
* Threading:
* - All public methods are safe to call from any thread; mutations are guarded by
* [controllerLock] for synchronous methods and by the facade's session mutex for
* suspend methods.
* - The decoder itself is single-threaded (MediaCodecVideoDecoder is @Synchronized).
*
* This class does **not** decide session restart policy; it reports errors and lets
* [NativeCoreFacade] decide whether to restart the scrcpy session.
*/
internal class VideoDecoderController(
private val renderer: PersistentVideoRenderer,
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val controllerLock = Any()
private var decoder: MediaCodecVideoDecoder? = null
@Volatile
private var currentSessionInfo: Scrcpy.Session.SessionInfo? = null
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
@Volatile
private var latestConfigPacket: CachedPacket? = null
/**
* The session info currently associated with the decoder (or the most recently released
* one). Returns null if no session has ever been bound.
*
* The facade uses this instead of maintaining its own copy, so there is a single source
* of truth kept in sync by [ensureDecoder] / [rebuildDecoderForSize] / the
* `onOutputSizeChanged` callback.
*/
fun getCurrentSessionInfo(): Scrcpy.Session.SessionInfo? = currentSessionInfo
// ---------- Listeners ----------
fun addVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.add(listener)
fun removeVideoSizeListener(listener: (Int, Int) -> Unit) = videoSizeListeners.remove(listener)
fun addVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.add(listener)
fun removeVideoFpsListener(listener: (Float) -> Unit) = videoFpsListeners.remove(listener)
// ---------- Session lifecycle ----------
/**
* Tear down everything: release the decoder and drop bootstrap state.
* Called by the facade when a scrcpy session stops or when the controller is closed.
*/
fun releaseAll() {
synchronized(controllerLock) {
runCatching { decoder?.release() }
decoder = null
currentSessionInfo = null
}
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
}
/**
* Reset bootstrap state without touching the decoder (used on session start before a new
* decoder is created).
*/
fun resetBootstrap() {
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
}
// ---------- Surface management ----------
/**
* Try to switch the active decoder's output surface to the renderer's current decoder
* surface. Returns true if the switch succeeded (or was unnecessary), false if the
* caller needs to recreate the decoder.
*/
fun trySwitchDecoderSurface(): Boolean {
synchronized(controllerLock) {
val current = decoder ?: return false
val switched = current.switchOutputSurface(renderer.getDecoderSurface())
Log.i(TAG, "trySwitchDecoderSurface(): success=$switched")
return switched
}
}
/**
* Attach the display surface to the renderer. Does not touch the decoder directly.
*/
fun attachDisplaySurface(surface: Surface) {
renderer.attachDisplaySurface(surface)
}
/**
* Detach the display surface. When [releaseDecoder] is true the decoder is also released
* because the backing surface is being destroyed.
*/
fun detachDisplaySurface(surface: Surface?, releaseDecoder: Boolean) {
renderer.detachDisplaySurface(surface, releaseSurface = false)
if (releaseDecoder) {
synchronized(controllerLock) {
Log.i(TAG, "detachDisplaySurface(): releasing decoder with destroyed surface")
runCatching { decoder?.release() }
decoder = null
}
}
}
/**
* Attach a recording surface to the renderer. If no decoder exists yet but session info
* is known, a new decoder is created.
*/
fun attachRecordSurface(
surface: Surface,
width: Int,
height: Int,
onFrameRendered: ((Long) -> Unit)?,
) {
renderer.attachRecordSurface(surface, width, height, onFrameRendered)
}
fun detachRecordSurface(surface: Surface?, releaseSurface: Boolean) {
renderer.detachRecordSurface(surface, releaseSurface)
}
// ---------- Decoder creation / rebuild ----------
/**
* Create a decoder for [session] if none exists. Should be called when a display or
* recording surface is attached and the session size is known.
*
* Returns true if a decoder exists after the call (either pre-existing or newly created).
*/
fun ensureDecoder(session: Scrcpy.Session.SessionInfo): Boolean {
synchronized(controllerLock) {
val current = decoder
if (current != null && current.isUsable()) return true
if (session.width <= 0 || session.height <= 0) {
Log.i(
TAG,
"ensureDecoder(): defer decoder, session size not yet known (${session.width}x${session.height})",
)
return false
}
// If a damaged decoder exists, release it first.
if (current != null) {
Log.i(TAG, "ensureDecoder(): releasing damaged decoder before rebuild")
runCatching { current.release() }
decoder = null
}
currentSessionInfo = session
createOrReplaceDecoderLocked(session, recreateSurface = false)
return decoder != null
}
}
/**
* Rebuild the decoder when the video size changes (e.g. flex-display rotation).
*
* - Recreates the persistent decoder surface to avoid OMX buffer conflicts on chips
* like MTK that fail when a new codec is configured against a surface with leftover
* buffers from a previously released codec.
* - Caches the new session info so future size-change checks can compare.
*
* Returns true if the decoder was rebuilt, false if skipped (same size, no decoder, etc.).
*/
suspend fun rebuildDecoderForSize(session: Scrcpy.Session.SessionInfo): Boolean {
val current = synchronized(controllerLock) { currentSessionInfo }
if (current != null && current.width == session.width && current.height == session.height) {
return false
}
val needRecreateSurface: Boolean
synchronized(controllerLock) {
if (decoder == null) {
// Initial creation (deferred until first session packet).
Log.i(TAG, "rebuildDecoderForSize(): create decoder ${session.width}x${session.height}")
currentSessionInfo = session
createOrReplaceDecoderLocked(session, recreateSurface = false)
return decoder != null
}
// Release the old decoder now so OMX stops writing to the surface, then
// drop the lock for the blocking delay + surface recreation below.
Log.i(
TAG,
"rebuildDecoderForSize(): rebuild decoder ${current?.width}x${current?.height} -> ${session.width}x${session.height}",
)
currentSessionInfo = session
runCatching { decoder?.release() }
decoder = null
needRecreateSurface = true
}
// Give OMX time to return buffers to the old native window before we tear down
// the SurfaceTexture, then recreate the surface. Both block, so run on IO.
//
// No lock is held here: the old decoder is already released and `decoder` is null,
// so feed() will no-op. The facade's sessionLifecycleMutex ensures no other
// lifecycle operation runs concurrently.
withContext(Dispatchers.IO) {
delay(50)
runCatching { renderer.recreateDecoderSurface() }
}
synchronized(controllerLock) {
createOrReplaceDecoderLocked(session, recreateSurface = false)
return decoder != null
}
}
/**
* Core method: construct a new [MediaCodecVideoDecoder] and replay bootstrap packets into it.
*
* Caller must have already released the old decoder (if any) and, when needed, recreated
* the persistent surface. Must be called while holding [controllerLock].
*/
private fun createOrReplaceDecoderLocked(
session: Scrcpy.Session.SessionInfo,
recreateSurface: Boolean,
) {
if (recreateSurface) {
// Should not happen in the current call path — surface recreation is handled
// by rebuildDecoderForSize outside the lock. Keep the parameter for clarity.
error("recreateSurface must be handled by caller")
}
val surface = renderer.getDecoderSurface()
val mime = session.codec?.mime ?: "video/avc"
Log.i(
TAG,
"createOrReplaceDecoder(): codec=${session.codec?.string ?: "null"}, " +
"size=${session.width}x${session.height}, " +
"recreateSurface=$recreateSurface, " +
"persistent=true",
)
val newDecoder = try {
MediaCodecVideoDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = mime,
onOutputSizeChanged = { width, height ->
val current = currentSessionInfo
if (current == null || (current.width == width && current.height == height)) {
return@MediaCodecVideoDecoder
}
Log.i(
TAG,
"videoSizeChanged(): ${current.width}x${current.height} -> ${width}x${height}",
)
currentSessionInfo = current.copy(width = width, height = height)
mainHandler.post {
videoSizeListeners.forEach { listener ->
runCatching { listener(width, height) }
}
}
},
onFpsUpdated = { fps ->
mainHandler.post {
videoFpsListeners.forEach { listener ->
runCatching { listener(fps) }
}
}
},
)
} catch (e: DecoderException) {
Log.e(TAG, "createOrReplaceDecoder(): decoder init failed for ${e.mime} ${e.width}x${e.height}", e)
// Surface already exists; leave decoder null. The facade will see isUsable()==false
// and can trigger downgrade/restart.
return
}
decoder = newDecoder
replayBootstrapPackets(newDecoder)
}
// ---------- Packet feeding ----------
/**
* Cache a bootstrap packet and feed it to the active decoder if one exists.
*
* Returns true if the decoder is still usable after feeding, false if the decoder
* entered an error state (the facade should restart the session in that case).
*/
fun feed(packet: Scrcpy.Session.VideoPacket): Boolean {
cacheBootstrapPacket(packet)
val dec = synchronized(controllerLock) { decoder } ?: return true
runCatching {
dec.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig,
)
}
return dec.isUsable()
}
/**
* Whether the current decoder (if any) is still usable.
*/
fun isDecoderUsable(): Boolean {
val dec = synchronized(controllerLock) { decoder } ?: return false
return dec.isUsable()
}
// ---------- Bootstrap packet cache ----------
private fun replayBootstrapPackets(decoder: MediaCodecVideoDecoder) {
val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
if (snapshot.isEmpty()) {
return
}
Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}")
snapshot.forEachIndexed { index, packet ->
if (!decoder.isUsable()) {
Log.w(TAG, "replayBootstrapPackets(): decoder unusable, aborting replay at $index")
return
}
runCatching {
decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
}
}
}
private fun cacheBootstrapPacket(packet: Scrcpy.Session.VideoPacket) {
val cached = CachedPacket(
data = packet.data.copyOf(),
ptsUs = packet.ptsUs,
isConfig = packet.isConfig,
isKeyFrame = packet.isKeyFrame,
)
synchronized(bootstrapLock) {
if (cached.isConfig) {
latestConfigPacket = cached
bootstrapPackets.clear()
bootstrapPackets.addLast(cached)
return
}
if (cached.isKeyFrame) {
bootstrapPackets.clear()
latestConfigPacket?.let { bootstrapPackets.addLast(it) }
bootstrapPackets.addLast(cached)
return
}
while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) {
bootstrapPackets.removeFirst()
}
bootstrapPackets.addLast(cached)
}
}
private data class CachedPacket(
val data: ByteArray,
val ptsUs: Long,
val isConfig: Boolean,
val isKeyFrame: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CachedPacket
if (ptsUs != other.ptsUs) return false
if (isConfig != other.isConfig) return false
if (isKeyFrame != other.isKeyFrame) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = ptsUs.hashCode()
result = 31 * result + isConfig.hashCode()
result = 31 * result + isKeyFrame.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
companion object {
private const val TAG = "VideoDecoderController"
private const val MAX_BOOTSTRAP_PACKETS = 90
}
}

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(
startAppOverride
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.let { options.copy(startApp = it) } ?.let { options.copy(startApp = it) }
?: options ?: 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

@@ -2,8 +2,11 @@ package io.github.miuzarte.scrcpyforandroid.pages
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -59,7 +62,7 @@ fun FileManagerScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
val pathStack by viewModel.pathStack.collectAsState() val breadcrumbState by viewModel.breadcrumbState.collectAsState()
val currentPath by viewModel.currentPath.collectAsState() val currentPath by viewModel.currentPath.collectAsState()
val cachedEntries by viewModel.cachedEntries.collectAsState() val cachedEntries by viewModel.cachedEntries.collectAsState()
val loading by viewModel.loading.collectAsState() val loading by viewModel.loading.collectAsState()
@@ -117,7 +120,7 @@ fun FileManagerScreen(
if (pendingTreeDownload != null) treeLauncher.launch(null) if (pendingTreeDownload != null) treeLauncher.launch(null)
} }
DisposableEffect(pathStack.size) { DisposableEffect(canNavigateUp) {
onCanNavigateUpChange(canNavigateUp) onCanNavigateUpChange(canNavigateUp)
onNavigateUpActionChange?.invoke(viewModel::navigateUp) onNavigateUpActionChange?.invoke(viewModel::navigateUp)
onDispose { onDispose {
@@ -144,7 +147,7 @@ fun FileManagerScreen(
haptic.contextClick() haptic.contextClick()
viewModel.navigateUp() viewModel.navigateUp()
}, },
enabled = pathStack.size > 1, enabled = canNavigateUp,
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack, imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
@@ -153,30 +156,21 @@ fun FileManagerScreen(
} }
}, },
bottomContent = { bottomContent = {
Text( val breadcrumbItems = remember(breadcrumbState.stack) {
text = currentPath, breadcrumbState.stack.map { path ->
modifier = Modifier BreadcrumbItem(
.fillMaxWidth() path = path,
.combinedClickable( text = path.substringAfterLast('/').ifEmpty { "/" },
onClick = {
haptic.contextClick()
pathInput = currentPath
showPathDialog = true
},
onLongClick = {
// 自带 haptic
pathInput = currentPath
showPathDialog = true
},
) )
.padding( }
start = UiSpacing.PageHorizontal, }
end = UiSpacing.PageHorizontal, BreadcrumbBar(
bottom = UiSpacing.Medium, items = breadcrumbItems,
), onItemClick = { index ->
softWrap = false, haptic.contextClick()
overflow = TextOverflow.Ellipsis, viewModel.jumpToPath(breadcrumbState.stack[index])
color = colorScheme.onSurfaceVariantSummary, },
highlightIndex = breadcrumbState.highlightIndex,
) )
}, },
actions = { actions = {
@@ -241,6 +235,13 @@ fun FileManagerScreen(
OverlayIconDropdownMenu( OverlayIconDropdownMenu(
entry = DropdownEntry( entry = DropdownEntry(
items = listOf( items = listOf(
DropdownItem(
text = stringResource(R.string.fm_goto_path),
onClick = {
pathInput = currentPath
showPathDialog = true
},
),
DropdownItem( DropdownItem(
text = stringResource(R.string.fm_menu_create_folder), text = stringResource(R.string.fm_menu_create_folder),
onClick = { onClick = {
@@ -708,3 +709,43 @@ private fun buildDetailsText(
} }
return details.toString() return details.toString()
} }
private data class BreadcrumbItem(
val path: String,
val text: String,
)
@Composable
private fun BreadcrumbBar(
items: List<BreadcrumbItem>,
onItemClick: (Int) -> Unit,
highlightIndex: Int,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = UiSpacing.Medium),
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, item ->
if (index > 0) {
Text(
text = " / ",
color = colorScheme.onSurface.copy(alpha = 0.5f),
fontSize = 12.sp,
)
}
val isHighlighted = index == highlightIndex
Text(
text = item.text,
color = if (isHighlighted) colorScheme.primary else colorScheme.onSurface,
fontSize = 12.sp,
modifier = Modifier
.clickable(onClick = { onItemClick(index) })
.padding(vertical = 4.dp),
)
}
}
}

View File

@@ -36,17 +36,60 @@ internal class FileManagerViewModel: ViewModel() {
) )
private val asBundle = asBundleSync.value private val asBundle = asBundleSync.value
private val _pathStack = MutableStateFlow(buildPathStack(INITIAL_REMOTE_PATH)) private val _currentPath = MutableStateFlow(INITIAL_REMOTE_PATH)
val pathStack: StateFlow<List<String>> = _pathStack.asStateFlow() val currentPath: StateFlow<String> = _currentPath.asStateFlow()
val currentPath: StateFlow<String> = _pathStack
.map { it.lastOrNull() ?: INITIAL_REMOTE_PATH } private val pathHistory = ArrayDeque<String>().apply { addLast(INITIAL_REMOTE_PATH) }
private val historyCapacity = 20
private fun addToHistory(path: String) {
pathHistory.remove(path)
pathHistory.addLast(path)
while (pathHistory.size > historyCapacity) pathHistory.removeFirst()
}
private fun trimHistory(path: String) {
pathHistory.retainAll { it == path || it.startsWith("$path/") || path.startsWith("$it/") }
}
private fun longestHistoricalPrefix(current: String): String {
val prefixMatch: (String) -> Boolean =
if (current == ROOT_REMOTE_PATH) {
{ true }
} else {
{ hist -> hist == current || hist.startsWith("$current/") }
}
var best = current
var bestLen = current.length
pathHistory.forEach { hist ->
if (hist.length > bestLen && prefixMatch(hist)) {
best = hist
bestLen = hist.length
}
}
return best
}
internal data class BreadcrumbState(
val stack: List<String>,
val highlightIndex: Int,
)
val breadcrumbState: StateFlow<BreadcrumbState> = _currentPath
.map { current ->
val longest = longestHistoricalPrefix(current)
val stack = buildPathStack(longest)
val highlight = buildPathStack(current).lastIndex
BreadcrumbState(stack = stack, highlightIndex = highlight)
}
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
INITIAL_REMOTE_PATH, BreadcrumbState(buildPathStack(INITIAL_REMOTE_PATH), buildPathStack(INITIAL_REMOTE_PATH).lastIndex),
) )
val canNavigateUp: StateFlow<Boolean> = _pathStack
.map { it.size > 1 } val canNavigateUp: StateFlow<Boolean> = _currentPath
.map { it != ROOT_REMOTE_PATH }
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
@@ -145,21 +188,31 @@ internal class FileManagerViewModel: ViewModel() {
} }
fun navigateUp(): Boolean { fun navigateUp(): Boolean {
val stack = _pathStack.value val current = _currentPath.value
if (stack.size <= 1) return false if (current == ROOT_REMOTE_PATH) return false
_pathStack.update { it.dropLast(1) } val parent = current.substringBeforeLast('/', ROOT_REMOTE_PATH)
_currentPath.value = parent
trimHistory(parent)
return true return true
} }
fun jumpToPath(rawPath: String) { fun jumpToPath(rawPath: String) {
val normalized = normalizePath(rawPath) val normalized = normalizePath(rawPath)
_pathStack.value = buildPathStack(normalized) _currentPath.value = normalized
addToHistory(normalized)
trimHistory(normalized)
}
private fun pushPath(path: String) {
_currentPath.value = path
addToHistory(path)
trimHistory(path)
} }
fun openEntry(entry: RemoteFileEntry) { fun openEntry(entry: RemoteFileEntry) {
when { when {
entry.isDirectory -> { entry.isDirectory -> {
_pathStack.update { it + normalizePath(entry.fullPath) } pushPath(normalizePath(entry.fullPath))
} }
entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> {

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

@@ -1779,6 +1779,16 @@ internal fun ScrcpyAllOptionsPage(
) )
}, },
) )
SwitchPreference(
title = stringResource(R.string.scrcpyopt_ignore_video_encoder_constraints),
summary = "--ignore-video-encoder-constraints",
checked = soBundle.ignoreVideoEncoderConstraints,
onCheckedChange = {
soBundle = soBundle.copy(
ignoreVideoEncoderConstraints = it,
)
},
)
} }
} }

View File

@@ -467,6 +467,16 @@ fun SettingsPage(
) )
}, },
) )
SwitchPreference(
title = stringResource(R.string.pref_title_downsize_on_decode_error),
summary = stringResource(R.string.pref_summary_downsize_on_decode_error),
checked = asBundle.downsizeOnDecodeError,
onCheckedChange = {
asBundle = asBundle.copy(
downsizeOnDecodeError = it,
)
},
)
SwitchPreference( SwitchPreference(
title = stringResource(R.string.pref_title_debug_info), title = stringResource(R.string.pref_title_debug_info),
summary = stringResource(R.string.pref_summary_debug_info), summary = stringResource(R.string.pref_summary_debug_info),

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

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.pages package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Typeface import android.graphics.Typeface
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
@@ -52,10 +53,10 @@ import kotlin.math.max
private const val FONT_SCALE_STEP_THRESHOLD = 1.08f private const val FONT_SCALE_STEP_THRESHOLD = 1.08f
private const val LOG_TAG = "TerminalScreen" private const val LOG_TAG = "TerminalScreen"
private fun terminalFontFile(context: android.content.Context) = private fun terminalFontFile(context: Context) =
File(context.filesDir, "terminal/font.ttf") File(context.filesDir, "terminal/font.ttf")
private fun loadTerminalTypeface(context: android.content.Context): Typeface? { private fun loadTerminalTypeface(context: Context): Typeface? {
val file = terminalFontFile(context) val file = terminalFontFile(context)
if (!file.exists()) return null if (!file.exists()) return null
return runCatching { Typeface.createFromFile(file) }.getOrNull() return runCatching { Typeface.createFromFile(file) }.getOrNull()

View File

@@ -212,6 +212,12 @@ data class ClientOptions(
// --flex-display // --flex-display
var flexDisplay: Boolean = false, // to server var flexDisplay: Boolean = false, // to server
// --ignore-video-encoder-constraints
var ignoreVideoEncoderConstraints: Boolean = false, // to server
// --no-terminal-title
// var updateTerminalTitle: Boolean = true,
) { ) {
enum class KeyInjectMode(val string: String) { enum class KeyInjectMode(val string: String) {
MIXED("mixed"), MIXED("mixed"),
@@ -633,6 +639,7 @@ data class ClientOptions(
vdSystemDecorations = vdSystemDecorations, vdSystemDecorations = vdSystemDecorations,
keepActive = keepActive, keepActive = keepActive,
flexDisplay = flexDisplay, flexDisplay = flexDisplay,
ignoreVideoEncoderConstraints = ignoreVideoEncoderConstraints,
list = list, list = list,
) )
} }

View File

@@ -116,9 +116,9 @@ class Scrcpy(
companion object { companion object {
private const val TAG = "Scrcpy" private const val TAG = "Scrcpy"
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.0" const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v4.1"
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.0" const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v4.1"
const val DEFAULT_SERVER_VERSION = "4.0" const val DEFAULT_SERVER_VERSION = "4.1"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
// Regex patterns for parsing server output // Regex patterns for parsing server output
@@ -202,7 +202,7 @@ class Scrcpy(
// Setup video consumer (notify NativeCoreFacade to setup decoders) // Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) { if (options.video) {
NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy) NativeCoreFacade.onScrcpySessionStarted(info, session, this@Scrcpy, options)
} }
// Setup audio player // Setup audio player
@@ -389,6 +389,10 @@ class Scrcpy(
session.setClipboard(text, paste) session.setClipboard(text, paste)
} }
suspend fun scanFile(path: String) = withContext(Dispatchers.IO) {
session.scanFile(path)
}
suspend fun injectTouch( suspend fun injectTouch(
action: Int, action: Int,
pointerId: Long, pointerId: Long,
@@ -1317,6 +1321,10 @@ class Scrcpy(
withControlWriter("setClipboard") { setClipboard(text, paste) } withControlWriter("setClipboard") { setClipboard(text, paste) }
} }
suspend fun scanFile(path: String) = mutex.withLock {
withControlWriter("scanFile") { scanFile(path) }
}
suspend fun injectTouch( suspend fun injectTouch(
action: Int, action: Int,
pointerId: Long, pointerId: Long,
@@ -1775,6 +1783,16 @@ class Scrcpy(
output.flush() output.flush()
} }
@Synchronized
fun scanFile(path: String) {
val bytes = path.toByteArray(Charsets.UTF_8)
require(bytes.size <= 256) { "scan file path is too long (max 256 bytes)" }
output.writeByte(TYPE_SCAN_FILE)
output.writeInt(bytes.size)
output.write(bytes)
output.flush()
}
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) { private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
output.writeInt(x) output.writeInt(x)
output.writeInt(y) output.writeInt(y)
@@ -1836,6 +1854,7 @@ class Scrcpy(
private const val TYPE_CAMERA_ZOOM_IN = 19 private const val TYPE_CAMERA_ZOOM_IN = 19
private const val TYPE_CAMERA_ZOOM_OUT = 20 private const val TYPE_CAMERA_ZOOM_OUT = 20
private const val TYPE_RESIZE_DISPLAY = 21 private const val TYPE_RESIZE_DISPLAY = 21
private const val TYPE_SCAN_FILE = 22
private const val SEQUENCE_INVALID = 0L private const val SEQUENCE_INVALID = 0L

View File

@@ -86,6 +86,7 @@ data class ServerParams(
val vdSystemDecorations: Boolean, val vdSystemDecorations: Boolean,
val keepActive: Boolean, val keepActive: Boolean,
val flexDisplay: Boolean, val flexDisplay: Boolean,
val ignoreVideoEncoderConstraints: Boolean,
val list: ListOptions, val list: ListOptions,
) { ) {
@@ -268,6 +269,9 @@ data class ServerParams(
if (flexDisplay) { if (flexDisplay) {
cmd.add("flex_display=true") cmd.add("flex_display=true")
} }
if (ignoreVideoEncoderConstraints) {
cmd.add("ignore_video_encoder_constraints=true")
}
if (displayImePolicy != DisplayImePolicy.UNDEFINED) { if (displayImePolicy != DisplayImePolicy.UNDEFINED) {
cmd.add("display_ime_policy=${displayImePolicy.string}") cmd.add("display_ime_policy=${displayImePolicy.string}")
} }

View File

@@ -82,18 +82,21 @@ class Shared {
enum class Codec( enum class Codec(
val string: String, val string: String,
val displayName: String, val displayName: String,
val mime: String,
val type: Type, val type: Type,
val id: Int, val id: Int,
val isLossless: Boolean, val isLossless: Boolean,
) { ) {
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing H264("h264", "H.264", "video/avc", Type.VIDEO, 0x68323634, false), // default, ignore when passing
H265("h265", "H.265", Type.VIDEO, 0x68323635, false), H265("h265", "H.265", "video/hevc", Type.VIDEO, 0x68323635, false),
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false), AV1("av1", "AV1", "video/av01", Type.VIDEO, 0x00617631, false),
VP8("vp8", "VP8", "video/x-vnd.on2.vp8", Type.VIDEO, 0x00767038, false),
VP9("vp9", "VP9", "video/x-vnd.on2.vp9", Type.VIDEO, 0x00767039, false),
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing OPUS("opus", "Opus", "audio/opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false), AAC("aac", "AAC", "audio/mp4a-latm", Type.AUDIO, 0x00616163, false),
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true), FLAC("flac", "FLAC", "audio/flac", Type.AUDIO, 0x666c6163, true),
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw RAW("raw", "RAW", "audio/raw", Type.AUDIO, 0x00726177, true); // wav raw
enum class Type { VIDEO, AUDIO } enum class Type { VIDEO, AUDIO }

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

@@ -127,6 +127,10 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
booleanPreferencesKey("low_latency"), booleanPreferencesKey("low_latency"),
false, false,
) )
val DOWNSIZE_ON_DECODE_ERROR = Pair(
booleanPreferencesKey("downsize_on_decode_error"),
true,
)
val FULLSCREEN_DEBUG_INFO = Pair( val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"), booleanPreferencesKey("fullscreen_debug_info"),
false, false,
@@ -318,6 +322,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy // Scrcpy
val lowLatency: Boolean, val lowLatency: Boolean,
val downsizeOnDecodeError: Boolean,
val fullscreenDebugInfo: Boolean, val fullscreenDebugInfo: Boolean,
val hideSimpleConfigItems: Boolean, val hideSimpleConfigItems: Boolean,
val previewCardOnTop: Boolean, val previewCardOnTop: Boolean,
@@ -383,6 +388,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy // Scrcpy
bundleField(LOW_LATENCY) { it.lowLatency }, bundleField(LOW_LATENCY) { it.lowLatency },
bundleField(DOWNSIZE_ON_DECODE_ERROR) { it.downsizeOnDecodeError },
bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo }, bundleField(FULLSCREEN_DEBUG_INFO) { it.fullscreenDebugInfo },
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems }, bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { it.hideSimpleConfigItems },
bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop }, bundleField(PREVIEW_CARD_ON_TOP) { it.previewCardOnTop },
@@ -449,6 +455,7 @@ class AppSettings(context: Context): Settings(context, "AppSettings") {
// Scrcpy // Scrcpy
lowLatency = preferences.read(LOW_LATENCY), lowLatency = preferences.read(LOW_LATENCY),
downsizeOnDecodeError = preferences.read(DOWNSIZE_ON_DECODE_ERROR),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS), hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP), previewCardOnTop = preferences.read(PREVIEW_CARD_ON_TOP),

View File

@@ -283,6 +283,10 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("flex_display"), booleanPreferencesKey("flex_display"),
false, false,
) )
val IGNORE_VIDEO_ENCODER_CONSTRAINTS = Pair(
booleanPreferencesKey("ignore_video_encoder_constraints"),
false,
)
fun defaultBundle() = Bundle( fun defaultBundle() = Bundle(
crop = CROP.defaultValue, crop = CROP.defaultValue,
@@ -351,6 +355,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = CAMERA_TORCH.defaultValue, cameraTorch = CAMERA_TORCH.defaultValue,
keepActive = KEEP_ACTIVE.defaultValue, keepActive = KEEP_ACTIVE.defaultValue,
flexDisplay = FLEX_DISPLAY.defaultValue, flexDisplay = FLEX_DISPLAY.defaultValue,
ignoreVideoEncoderConstraints = IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
) )
} }
@@ -422,6 +427,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
val cameraTorch: Boolean, val cameraTorch: Boolean,
val keepActive: Boolean, val keepActive: Boolean,
val flexDisplay: Boolean, val flexDisplay: Boolean,
val ignoreVideoEncoderConstraints: Boolean,
): Parcelable { ): Parcelable {
} }
@@ -491,6 +497,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
bundleField(CAMERA_TORCH) { it.cameraTorch }, bundleField(CAMERA_TORCH) { it.cameraTorch },
bundleField(KEEP_ACTIVE) { it.keepActive }, bundleField(KEEP_ACTIVE) { it.keepActive },
bundleField(FLEX_DISPLAY) { it.flexDisplay }, bundleField(FLEX_DISPLAY) { it.flexDisplay },
bundleField(IGNORE_VIDEO_ENCODER_CONSTRAINTS) { it.ignoreVideoEncoderConstraints },
) )
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences) val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
@@ -562,6 +569,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = preferences.read(CAMERA_TORCH), cameraTorch = preferences.read(CAMERA_TORCH),
keepActive = preferences.read(KEEP_ACTIVE), keepActive = preferences.read(KEEP_ACTIVE),
flexDisplay = preferences.read(FLEX_DISPLAY), flexDisplay = preferences.read(FLEX_DISPLAY),
ignoreVideoEncoderConstraints = preferences.read(IGNORE_VIDEO_ENCODER_CONSTRAINTS),
) )
suspend fun loadBundle() = loadBundle(::bundleFromPreferences) suspend fun loadBundle() = loadBundle(::bundleFromPreferences)
@@ -645,6 +653,7 @@ class ScrcpyOptions(context: Context): Settings(context, "ScrcpyOptions") {
cameraTorch = bundle.cameraTorch, cameraTorch = bundle.cameraTorch,
keepActive = bundle.keepActive, keepActive = bundle.keepActive,
flexDisplay = bundle.flexDisplay, flexDisplay = bundle.flexDisplay,
ignoreVideoEncoderConstraints = bundle.ignoreVideoEncoderConstraints,
) )
} }
@@ -716,6 +725,7 @@ internal fun encodeBundleToJson(bundle: ScrcpyOptions.Bundle): JSONObject =
.put("cameraTorch", bundle.cameraTorch) .put("cameraTorch", bundle.cameraTorch)
.put("keepActive", bundle.keepActive) .put("keepActive", bundle.keepActive)
.put("flexDisplay", bundle.flexDisplay) .put("flexDisplay", bundle.flexDisplay)
.put("ignoreVideoEncoderConstraints", bundle.ignoreVideoEncoderConstraints)
internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle { internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle {
val json = bundleJson ?: return ScrcpyOptions.defaultBundle() val json = bundleJson ?: return ScrcpyOptions.defaultBundle()
@@ -984,6 +994,10 @@ internal fun decodeBundleFromJson(bundleJson: JSONObject?): ScrcpyOptions.Bundle
"flexDisplay", "flexDisplay",
ScrcpyOptions.FLEX_DISPLAY.defaultValue, ScrcpyOptions.FLEX_DISPLAY.defaultValue,
), ),
ignoreVideoEncoderConstraints = json.optBooleanOrDefault(
"ignoreVideoEncoderConstraints",
ScrcpyOptions.IGNORE_VIDEO_ENCODER_CONSTRAINTS.defaultValue,
),
) )
} }

View File

@@ -1,6 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.storage package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -33,7 +34,7 @@ class ScrcpyProfiles(context: Context): Settings(context, "ScrcpyProfiles") {
val state: StateFlow<State> = createBundleState(::stateFromPreferences) val state: StateFlow<State> = createBundleState(::stateFromPreferences)
private fun stateFromPreferences(preferences: androidx.datastore.preferences.core.Preferences): State { private fun stateFromPreferences(preferences: Preferences): State {
val raw = preferences.read(PROFILES_JSON) val raw = preferences.read(PROFILES_JSON)
return normalizeState( return normalizeState(
runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList()), runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList()),

View File

@@ -63,14 +63,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import io.github.miuzarte.scrcpyforandroid.ui.confirm import io.github.miuzarte.scrcpyforandroid.ui.confirm
import io.github.miuzarte.scrcpyforandroid.ui.contextClick import io.github.miuzarte.scrcpyforandroid.ui.contextClick
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.* import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.AddCircle import top.yukonga.miuix.kmp.icon.extended.AddCircle
@@ -827,7 +822,6 @@ private fun PairingDialog(
show = showDialog, show = showDialog,
title = stringResource(R.string.device_pairing_title), title = stringResource(R.string.device_pairing_title),
summary = stringResource(R.string.device_pairing_desc), summary = stringResource(R.string.device_pairing_desc),
defaultWindowInsetsPadding = false,
onDismissRequest = { onDismissRequest = {
onDismissRequest() onDismissRequest()
}, },

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()
}
}

View File

@@ -168,6 +168,8 @@
<string name="section_screen_mirroring">投屏</string> <string name="section_screen_mirroring">投屏</string>
<string name="pref_title_low_latency_audio">低延迟音频(实验性)</string> <string name="pref_title_low_latency_audio">低延迟音频(实验性)</string>
<string name="pref_summary_low_latency_audio">启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用</string> <string name="pref_summary_low_latency_audio">启用后将尝试使用低延迟音频路径\n推荐配合 RAW PCM 编解码\n修改后建议划卡重启应用</string>
<string name="pref_title_downsize_on_decode_error">解码出错时自动降级</string>
<string name="pref_summary_downsize_on_decode_error">当本机解码器不支持当前视频分辨率时,自动以较低的 max_size 重启会话,关闭后仅提示错误</string>
<string name="pref_title_debug_info">启用调试信息</string> <string name="pref_title_debug_info">启用调试信息</string>
<string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string> <string name="pref_summary_debug_info">在全屏界面悬浮显示分辨率、帧率和触点信息</string>
<string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string> <string name="pref_title_hide_simple_settings">设备页隐藏简单设置项</string>
@@ -534,6 +536,7 @@
<string name="scrcpyopt_no_cleanup">禁用结束后清理</string> <string name="scrcpyopt_no_cleanup">禁用结束后清理</string>
<string name="scrcpyopt_no_cleanup_desc">默认情况下scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string> <string name="scrcpyopt_no_cleanup_desc">默认情况下scrcpy 会从设备中移除服务器二进制文件,并在退出时恢复设备状态(显示触摸、保持唤醒和电源模式)\n此选项将禁用此清理操作</string>
<string name="scrcpyopt_flex_display">自动调整尺寸以匹配界面</string> <string name="scrcpyopt_flex_display">自动调整尺寸以匹配界面</string>
<string name="scrcpyopt_ignore_video_encoder_constraints">忽略视频编码器约束</string>
<string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string> <string name="scrcpyopt_start_app">scrcpy 启动后打开应用</string>
<string name="scrcpyopt_native">本机</string> <string name="scrcpyopt_native">本机</string>
<string name="scrcpyopt_log_level">日志等级</string> <string name="scrcpyopt_log_level">日志等级</string>
@@ -574,6 +577,9 @@
<string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string> <string name="vm_clipboard_synced_paste">已同步本机剪贴板到设备并触发粘贴</string>
<string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string> <string name="vm_clipboard_paste_failed">本机剪贴板粘贴失败</string>
<string name="vm_session_disconnected">Scrcpy 连接已断开</string> <string name="vm_session_disconnected">Scrcpy 连接已断开</string>
<string name="vm_decoder_unsupported_size">设备解码器不支持 %1$dx%2$d正在降级为 max_size=%3$d 并重启</string>
<string name="vm_decoder_init_failed">解码器初始化失败:%1$s</string>
<string name="vm_decoder_error_restarting">解码器错误,正在重启会话</string>
<string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string> <string name="vm_ime_text_failed">输入法文本提交失败:%1$s</string>
<string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string> <string name="vm_adb_disconnected_device">ADB 已断开:%1$s</string>
<string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string> <string name="vm_mdns_updated">mDNS 发现新端口,已更新快速设备:%1$s:%2$s -> %1$s:%3$s</string>

View File

@@ -169,6 +169,8 @@
<string name="section_screen_mirroring">Screen mirroring</string> <string name="section_screen_mirroring">Screen mirroring</string>
<string name="pref_title_low_latency_audio">Low latency audio (experimental)</string> <string name="pref_title_low_latency_audio">Low latency audio (experimental)</string>
<string name="pref_summary_low_latency_audio">When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app</string> <string name="pref_summary_low_latency_audio">When enabled, will attempt to use low-latency audio path.\nRecommended to use with RAW PCM codec.\nAfter modification, it is recommended to swipe away and restart the app</string>
<string name="pref_title_downsize_on_decode_error">Downscale on decoder error</string>
<string name="pref_summary_downsize_on_decode_error">When the local decoder cannot handle the video resolution, automatically restart the session with a lower max_size. Disable to show an error instead</string>
<string name="pref_title_debug_info">Enable debug info</string> <string name="pref_title_debug_info">Enable debug info</string>
<string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string> <string name="pref_summary_debug_info">Display resolution, frame rate, and touch info as overlay in fullscreen</string>
<string name="pref_title_hide_simple_settings">Hide simple settings on device page</string> <string name="pref_title_hide_simple_settings">Hide simple settings on device page</string>
@@ -372,14 +374,14 @@
<string name="password_no_lock_screen_warn">Continuing will allow saving and filling lockscreen passwords without authentication protection</string> <string name="password_no_lock_screen_warn">Continuing will allow saving and filling lockscreen passwords without authentication protection</string>
<string name="password_agree">Agree</string> <string name="password_agree">Agree</string>
<string name="password_auth_lost_warn">Passwords will lose protection after disabling authentication</string> <string name="password_auth_lost_warn">Passwords will lose protection after disabling authentication</string>
<string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned.</string> <string name="password_auth_lost_detail">After disabling, filling passwords will no longer require authentication.\nAlso, passwords previously created with authentication will be burned</string>
<string name="password_continue_disable">Continue to disable</string> <string name="password_continue_disable">Continue to disable</string>
<string name="password_delete_confirm">Delete password</string> <string name="password_delete_confirm">Delete password</string>
<string name="password_delete_msg">Will delete %1$s</string> <string name="password_delete_msg">Will delete %1$s</string>
<string name="password_default_name">Password %1$d</string> <string name="password_default_name">Password %1$d</string>
<string name="password_cannot_be_empty">Password cannot be empty</string> <string name="password_cannot_be_empty">Password cannot be empty</string>
<string name="password_require_auth">Require authentication when filling passwords</string> <string name="password_require_auth">Require authentication when filling passwords</string>
<string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned.</string> <string name="password_require_auth_detail">After disabling, lockscreen passwords can be filled directly.\nAlso, passwords previously created with authentication will be burned</string>
<string name="password_no_auth_capability">Current device has no authentication capability</string> <string name="password_no_auth_capability">Current device has no authentication capability</string>
<string name="password_or_menu_hint">Or in the top-right menu</string> <string name="password_or_menu_hint">Or in the top-right menu</string>
<string name="password_status_invalidated">Invalidated</string> <string name="password_status_invalidated">Invalidated</string>
@@ -388,7 +390,7 @@
<string name="password_status_burned">Authenticated when created (burned)</string> <string name="password_status_burned">Authenticated when created (burned)</string>
<string name="password_rename">Rename password</string> <string name="password_rename">Rename password</string>
<string name="password_lockscreen_label">Lockscreen password</string> <string name="password_lockscreen_label">Lockscreen password</string>
<string name="password_disclaimer">Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully.</string> <string name="password_disclaimer">Disclaimer\n0. Cannot guarantee there are no bugs.\n1. The protection boundary of this feature only includes encrypted storage, on-demand authentication, and memory cleanup after use; it does not constitute an absolute security guarantee.\n2. Under root / posed / hook / debugger / malicious input method environments, passwords may still leak.\n3. This feature does not bypass system lock screen authentication; it is only for devices you have legally authorized control over.\n4. Disabling \"Require authentication when filling passwords\" will significantly reduce security; please choose carefully</string>
<!-- File Manager --> <!-- File Manager -->
<string name="fm_cd_parent">Parent directory</string> <string name="fm_cd_parent">Parent directory</string>
@@ -526,15 +528,16 @@
<string name="scrcpyopt_no_vd_destroy_content">Retain content when closing virtual display</string> <string name="scrcpyopt_no_vd_destroy_content">Retain content when closing virtual display</string>
<string name="scrcpyopt_no_vd_decorations">Disable virtual display system decorations</string> <string name="scrcpyopt_no_vd_decorations">Disable virtual display system decorations</string>
<string name="scrcpyopt_no_downsize_on_error">Disable auto-downscale on MediaCodec error</string> <string name="scrcpyopt_no_downsize_on_error">Disable auto-downscale on MediaCodec error</string>
<string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior.</string> <string name="scrcpyopt_no_downsize_on_error_desc">By default, when a MediaCodec error occurs, scrcpy automatically tries again with a lower resolution.\nThis option disables this behavior</string>
<string name="scrcpyopt_legacy_paste">Legacy paste (ASCII/English characters only)</string> <string name="scrcpyopt_legacy_paste">Legacy paste (ASCII/English characters only)</string>
<string name="scrcpyopt_key_inject_mode">Keyboard injection mode</string> <string name="scrcpyopt_key_inject_mode">Keyboard injection mode</string>
<string name="scrcpyopt_no_key_repeat">Do not forward repeated key events when a key is held down</string> <string name="scrcpyopt_no_key_repeat">Do not forward repeated key events when a key is held down</string>
<string name="scrcpyopt_no_clipboard_autosync">Disable automatic clipboard synchronization</string> <string name="scrcpyopt_no_clipboard_autosync">Disable automatic clipboard synchronization</string>
<string name="scrcpyopt_no_mouse_hover">Do not forward mouse hover events</string> <string name="scrcpyopt_no_mouse_hover">Do not forward mouse hover events</string>
<string name="scrcpyopt_no_cleanup">Disable cleanup on exit</string> <string name="scrcpyopt_no_cleanup">Disable cleanup on exit</string>
<string name="scrcpyopt_no_cleanup_desc">By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation.</string> <string name="scrcpyopt_no_cleanup_desc">By default, scrcpy removes the server binary from the device and restores device state on exit (show touches, stay awake, and power mode).\nThis option disables this cleanup operation</string>
<string name="scrcpyopt_flex_display">Automatically resize to match the screen</string> <string name="scrcpyopt_flex_display">Automatically resize to match the screen</string>
<string name="scrcpyopt_ignore_video_encoder_constraints">Ignore video encoder constraints</string>
<string name="scrcpyopt_start_app">Open app after scrcpy starts</string> <string name="scrcpyopt_start_app">Open app after scrcpy starts</string>
<string name="scrcpyopt_native">Native</string> <string name="scrcpyopt_native">Native</string>
<string name="scrcpyopt_log_level">Log level</string> <string name="scrcpyopt_log_level">Log level</string>
@@ -575,6 +578,9 @@
<string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string> <string name="vm_clipboard_synced_paste">Synced local clipboard to device and triggered paste</string>
<string name="vm_clipboard_paste_failed">Local clipboard paste failed</string> <string name="vm_clipboard_paste_failed">Local clipboard paste failed</string>
<string name="vm_session_disconnected">Scrcpy session disconnected</string> <string name="vm_session_disconnected">Scrcpy session disconnected</string>
<string name="vm_decoder_unsupported_size">Device decoder does not support %1$dx%2$d, downgrading to max_size=%3$d and restarting</string>
<string name="vm_decoder_init_failed">Decoder initialization failed: %1$s</string>
<string name="vm_decoder_error_restarting">Decoder error, restarting session</string>
<string name="vm_ime_text_failed">IME text submission failed: %1$s</string> <string name="vm_ime_text_failed">IME text submission failed: %1$s</string>
<string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string> <string name="vm_adb_disconnected_device">ADB disconnected: %1$s</string>
<string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string> <string name="vm_mdns_updated">mDNS discovered new port, updated quick device: %1$s:%2$s -> %1$s:%3$s</string>

View File

@@ -10,7 +10,7 @@
<item name="colorSecondaryVariant">@color/teal_700</item> <item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item> <item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. --> <!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item> <item name="android:statusBarColor">@android:color/transparent</item>
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
</style> </style>
</resources> </resources>

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"
@@ -15,7 +15,7 @@ androidxNavigation3 = "1.1.4"
junit = "4.13.2" junit = "4.13.2"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
miuix = "0.9.0" miuix = "0.9.3"
backdrop = "1.0.6" backdrop = "1.0.6"
material = "1.14.0" material = "1.14.0"
reorderable = "3.1.0" reorderable = "3.1.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

View File

@@ -7,5 +7,15 @@
"git-submodules": { "git-submodules": {
"enabled": false "enabled": false
}, },
"schedule": ["before 6am on Monday"] "schedule": [
"before 6am on Monday"
],
"packageRules": [
{
"matchPackageNames": [
"miuix"
],
"enabled": false
}
]
} }