Compare commits
14 Commits
5a1a30ad83
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca089428b8 | |||
| 37fa52fbf6 | |||
| a336734897 | |||
| 58eefc88c6 | |||
| db6c4b9bb8 | |||
| 491badd2aa | |||
| 4c7b24a4d5 | |||
| f7ff48ff6c | |||
|
|
b1ba9fcbea | ||
|
|
85f4e50aad | ||
|
|
190da2b0ab | ||
|
|
f92467700c | ||
|
|
12f9effd8f | ||
|
|
c9cb5b1034 |
16
.github/workflows/android.yml
vendored
16
.github/workflows/android.yml
vendored
@@ -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-*
|
||||||
|
|||||||
8
.github/workflows/renovate-check.yml
vendored
8
.github/workflows/renovate-check.yml
vendored
@@ -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-*
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,23 +1,45 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid
|
package io.github.miuzarte.scrcpyforandroid
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
|
||||||
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
|
import io.github.miuzarte.scrcpyforandroid.pages.MainScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
|
import io.github.miuzarte.scrcpyforandroid.password.BiometricGate
|
||||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
|
||||||
import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin
|
import io.github.miuzarte.scrcpyforandroid.password.hasAuthenticatedOrigin
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbConnectionCoordinator
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
// 生物认证需要 FragmentActivity
|
// 生物认证需要 FragmentActivity
|
||||||
@@ -63,8 +85,27 @@ class MainActivity: FragmentActivity() {
|
|||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val launchParams = AdbLaunchParams.fromIntent(intent)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MainScreen()
|
MaterialTheme {
|
||||||
|
if (launchParams != null) {
|
||||||
|
AppRuntime.adbLaunchParams = launchParams
|
||||||
|
}
|
||||||
|
|
||||||
|
var showMainScreen by remember { mutableStateOf(launchParams != null) }
|
||||||
|
|
||||||
|
if (showMainScreen) {
|
||||||
|
MainScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showMainScreen) {
|
||||||
|
QuickConnectDialog(
|
||||||
|
onConnect = { host, port -> doConnect(host, port) },
|
||||||
|
onMoreSettings = { showMainScreen = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +142,384 @@ class MainActivity: FragmentActivity() {
|
|||||||
return maxOf(width, height).toFloat() / minOf(width, height).toFloat()
|
return maxOf(width, height).toFloat() / minOf(width, height).toFloat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================== 快速连接弹窗 ========================
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickConnectDialog(
|
||||||
|
onConnect: (String, Int) -> Unit,
|
||||||
|
onMoreSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
var host by remember { mutableStateOf("") }
|
||||||
|
var port by remember { mutableStateOf("5555") }
|
||||||
|
var showHistory by remember { mutableStateOf(false) }
|
||||||
|
var showPermissionDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 无线配对
|
||||||
|
var showPairing by remember { mutableStateOf(false) }
|
||||||
|
var pairingHost by remember { mutableStateOf("") }
|
||||||
|
var pairingPort by remember { mutableStateOf("5555") }
|
||||||
|
var pairingCode by remember { mutableStateOf("") }
|
||||||
|
var pairingResult by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val pairingCoordinator = remember { DeviceAdbConnectionCoordinator() }
|
||||||
|
|
||||||
|
// 读取历史记录
|
||||||
|
val historyEntries = remember { loadConnectHistory() }
|
||||||
|
var history by remember { mutableStateOf(historyEntries) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (showHistory) {
|
||||||
|
HistoryScreen(
|
||||||
|
entries = history,
|
||||||
|
onSelect = { entry ->
|
||||||
|
host = entry.host
|
||||||
|
port = entry.port.toString()
|
||||||
|
showHistory = false
|
||||||
|
},
|
||||||
|
onDelete = { entry ->
|
||||||
|
history = history.filter { it != entry }
|
||||||
|
saveConnectHistory(history)
|
||||||
|
},
|
||||||
|
onBack = { showHistory = false },
|
||||||
|
)
|
||||||
|
} else if (showPairing) {
|
||||||
|
// 无线配对界面
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("无线配对", style = MaterialTheme.typography.titleMedium)
|
||||||
|
IconButton(onClick = { showPairing = false }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pairingHost,
|
||||||
|
onValueChange = { pairingHost = it },
|
||||||
|
label = { Text("主机地址 (IP)") },
|
||||||
|
placeholder = { Text("192.168.1.100") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pairingPort,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (newValue.all { it.isDigit() } && newValue.length <= 5) {
|
||||||
|
pairingPort = newValue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("端口") },
|
||||||
|
placeholder = { Text("5555") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pairingCode,
|
||||||
|
onValueChange = { pairingCode = it },
|
||||||
|
label = { Text("配对码(6位数字)") },
|
||||||
|
placeholder = { Text("123456") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pairingResult?.let { result ->
|
||||||
|
Text(
|
||||||
|
text = result,
|
||||||
|
color = if (result.contains("成功")) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val h = pairingHost.ifBlank { return@Button }
|
||||||
|
val p = pairingPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return@Button
|
||||||
|
val code = pairingCode.ifBlank { return@Button }
|
||||||
|
pairingResult = "正在配对..."
|
||||||
|
scope.launch {
|
||||||
|
val success = pairingCoordinator.pair(h, p, code)
|
||||||
|
pairingResult = if (success) "配对成功!" else "配对失败,请检查信息"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("开始配对")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 主弹窗界面
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Scrcpy For Android",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = host,
|
||||||
|
onValueChange = { host = it },
|
||||||
|
label = { Text("主机地址 (IP)") },
|
||||||
|
placeholder = { Text("192.168.1.100") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (newValue.all { it.isDigit() } && newValue.length <= 5) {
|
||||||
|
port = newValue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("端口") },
|
||||||
|
placeholder = { Text("5555") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 历史记录按钮
|
||||||
|
if (history.isNotEmpty()) {
|
||||||
|
TextButton(onClick = { showHistory = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.History,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("历史记录 (${history.size})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// 连接按钮
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val hostValue = host.ifBlank { "192.168.1.100" }
|
||||||
|
val portValue = port.toIntOrNull()?.takeIf { it in 1..65535 } ?: 5555
|
||||||
|
host = hostValue
|
||||||
|
port = portValue.toString()
|
||||||
|
|
||||||
|
val hasOverlay = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
Settings.canDrawOverlays(this@MainActivity)
|
||||||
|
} else true
|
||||||
|
|
||||||
|
if (!hasOverlay) {
|
||||||
|
showPermissionDialog = true
|
||||||
|
} else {
|
||||||
|
onConnect(hostValue, portValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("连接")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无线配对按钮
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showPairing = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("无线配对")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更多设置
|
||||||
|
TextButton(onClick = onMoreSettings) {
|
||||||
|
Text("更多设置 →")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 悬浮窗权限弹窗
|
||||||
|
if (showPermissionDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPermissionDialog = false },
|
||||||
|
title = { Text("需要悬浮窗权限") },
|
||||||
|
text = {
|
||||||
|
Text("悬浮窗功能需要\"显示在其他应用上层\"权限才能正常工作。\n\n请在系统设置中授予该权限后重试。")
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showPermissionDialog = false
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||||
|
android.net.Uri.parse("package:$packageName")
|
||||||
|
)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("去设置")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showPermissionDialog = false }) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doConnect(host: String, port: Int) {
|
||||||
|
// 保存历史记录
|
||||||
|
saveHostToHistory(host, port)
|
||||||
|
|
||||||
|
AppRuntime.adbLaunchParams = AdbLaunchParams(
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
fullscreen = true,
|
||||||
|
video = true,
|
||||||
|
audio = false,
|
||||||
|
)
|
||||||
|
startActivity(
|
||||||
|
StreamActivity.createIntent(this@MainActivity).apply {
|
||||||
|
putExtra("host", host)
|
||||||
|
putExtra("port", port)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 历史记录管理 ========================
|
||||||
|
|
||||||
|
private data class HostEntry(val host: String, val port: Int) {
|
||||||
|
fun toLabel() = "$host:$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveHostToHistory(host: String, port: Int) {
|
||||||
|
val entries = loadConnectHistory().toMutableList()
|
||||||
|
entries.removeAll { it.host == host && it.port == port }
|
||||||
|
entries.add(0, HostEntry(host, port))
|
||||||
|
// 最多保存 10 条
|
||||||
|
val trimmed = entries.take(10)
|
||||||
|
saveConnectHistory(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadConnectHistory(): List<HostEntry> {
|
||||||
|
return try {
|
||||||
|
val json = getSharedPreferences(HISTORY_PREFS, MODE_PRIVATE)
|
||||||
|
.getString(KEY_HISTORY, null) ?: return emptyList()
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
HostEntry(obj.getString("host"), obj.getInt("port"))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveConnectHistory(entries: List<HostEntry>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
entries.forEach { entry ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("host", entry.host)
|
||||||
|
put("port", entry.port)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getSharedPreferences(HISTORY_PREFS, MODE_PRIVATE).edit {
|
||||||
|
putString(KEY_HISTORY, arr.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HistoryScreen(
|
||||||
|
entries: List<HostEntry>,
|
||||||
|
onSelect: (HostEntry) -> Unit,
|
||||||
|
onDelete: (HostEntry) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "历史记录",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items(entries, key = { it.toLabel() }) { entry ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect(entry) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = entry.toLabel(),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "点击选择此地址",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onDelete(entry) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "删除",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal companion object {
|
internal companion object {
|
||||||
private const val PHONE_LANDSCAPE_LOCK_ASPECT_RATIO = 16f / 9f
|
private const val PHONE_LANDSCAPE_LOCK_ASPECT_RATIO = 16f / 9f
|
||||||
|
|
||||||
private const val LOCALE_PREFS = "locale_cache"
|
private const val LOCALE_PREFS = "locale_cache"
|
||||||
private const val KEY_LANGUAGE_TAG = "language_tag"
|
private const val KEY_LANGUAGE_TAG = "language_tag"
|
||||||
|
private const val HISTORY_PREFS = "connect_history"
|
||||||
|
private const val KEY_HISTORY = "hosts"
|
||||||
|
|
||||||
fun getAppLanguageTag(context: Context) =
|
fun getAppLanguageTag(context: Context) =
|
||||||
context.getSharedPreferences(LOCALE_PREFS, MODE_PRIVATE)
|
context.getSharedPreferences(LOCALE_PREFS, MODE_PRIVATE)
|
||||||
|
|||||||
@@ -1,150 +1,89 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid
|
package io.github.miuzarte.scrcpyforandroid
|
||||||
|
|
||||||
import android.R.drawable
|
|
||||||
import android.app.PictureInPictureUiState
|
|
||||||
import android.app.RemoteAction
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.core.app.PictureInPictureParamsCompat.Builder
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.pip.BasicPictureInPicture
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.pages.QuickLaunchScreen
|
||||||
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
|
import io.github.miuzarte.scrcpyforandroid.pages.StreamScreen
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
import io.github.miuzarte.scrcpyforandroid.services.AppScreenOn
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.PictureInPictureActionReceiver
|
import io.github.miuzarte.scrcpyforandroid.widgets.FloatingVideoWindow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
class StreamActivity: FragmentActivity() {
|
class StreamActivity: FragmentActivity() {
|
||||||
private val basicPip by lazy { BasicPictureInPicture(this, ContextCompat.getMainExecutor(this)) }
|
|
||||||
|
|
||||||
private val pipActionReceiver = PictureInPictureActionReceiver()
|
internal val floatingWindow by lazy { FloatingVideoWindow(this) }
|
||||||
private var isPipActionReceiverRegistered = false
|
private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
// 是否处于 pip
|
|
||||||
// 回到全屏时会因重建而变回初始值
|
|
||||||
private val _pipModeState = MutableStateFlow(false)
|
|
||||||
val pipModeState: StateFlow<Boolean> = _pipModeState
|
|
||||||
|
|
||||||
val pipStopAction: RemoteAction by lazy {
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(this, drawable.ic_menu_close_clear_cancel),
|
|
||||||
getString(R.string.password_stop_mirroring),
|
|
||||||
getString(R.string.password_stop_mirroring),
|
|
||||||
PictureInPictureActionReceiver.createPendingIntent(this),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每次 进出全屏/进出画中画
|
|
||||||
// 都会重建 activity
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
currentActivityRef = WeakReference(this)
|
currentActivityRef = WeakReference(this)
|
||||||
AppScreenOn.register(window)
|
AppScreenOn.register(window)
|
||||||
|
|
||||||
registerPipActionReceiver()
|
// 检测是否为快速启动模式(从 MainActivity 对话框直接跳转)
|
||||||
|
val isQuickLaunch = intent.hasExtra("host")
|
||||||
// 声明要画中画
|
|
||||||
basicPip.setEnabled(true)
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
StreamScreen(activity = this)
|
if (isQuickLaunch) {
|
||||||
}
|
QuickLaunchScreen(activity = this)
|
||||||
|
} else {
|
||||||
/*
|
StreamScreen(activity = this)
|
||||||
// 可能以后有用
|
|
||||||
basicPip.addOnPictureInPictureEventListener(
|
|
||||||
executor = mainExecutor,
|
|
||||||
listener = object : PictureInPictureDelegate.OnPictureInPictureEventListener {
|
|
||||||
override fun onPictureInPictureEvent(
|
|
||||||
event: PictureInPictureDelegate.Event,
|
|
||||||
config: Configuration?,
|
|
||||||
) {
|
|
||||||
// MIUI 只有这些事件
|
|
||||||
when (event) {
|
|
||||||
PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> {}
|
|
||||||
PictureInPictureDelegate.Event.ENTER_ANIMATION_END -> {}
|
|
||||||
|
|
||||||
PictureInPictureDelegate.Event.STASHED -> {}
|
|
||||||
PictureInPictureDelegate.Event.UNSTASHED -> {}
|
|
||||||
|
|
||||||
// 收不到
|
|
||||||
// PictureInPictureDelegate.Event.ENTERED -> {}
|
|
||||||
// PictureInPictureDelegate.Event.EXITED -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun configurePip(block: Builder.() -> Builder) =
|
override fun onStart() {
|
||||||
basicPip.setPictureInPictureParams(Builder().block().build())
|
super.onStart()
|
||||||
|
// 回到前台时隐藏悬浮窗
|
||||||
|
floatingWindow.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
if (AppRuntime.scrcpy?.currentSessionState?.value != null) {
|
||||||
|
// 进入后台时显示悬浮窗
|
||||||
|
floatingWindow.show(
|
||||||
|
onClose = {
|
||||||
|
// 关闭:停止 scrcpy 会话
|
||||||
|
activityScope.launch(Dispatchers.IO) {
|
||||||
|
AppRuntime.scrcpy?.stop(Scrcpy.StopReason.USER)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onFullscreen = {
|
||||||
|
// 全屏:重新打开 StreamActivity
|
||||||
|
if (!isFinishing && !isDestroyed) {
|
||||||
|
// Activity 仍在,直接将其带到前台
|
||||||
|
startActivity(createIntent(this).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Activity 已销毁,创建新的
|
||||||
|
startActivity(createIntent(this))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
currentActivityRef?.get()
|
currentActivityRef?.get()
|
||||||
?.takeIf { it === this }
|
?.takeIf { it === this }
|
||||||
?.let { currentActivityRef = null }
|
?.let { currentActivityRef = null }
|
||||||
AppScreenOn.unregister(window)
|
AppScreenOn.unregister(window)
|
||||||
unregisterPipActionReceiver()
|
floatingWindow.destroy()
|
||||||
|
activityScope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
// 回到全屏也会停止, 暂时不做
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
|
|
||||||
if (_pipModeState.value) {
|
|
||||||
Thread {
|
|
||||||
runBlocking {
|
|
||||||
AppRuntime.scrcpy?.stop()
|
|
||||||
}
|
|
||||||
}.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
//- onPictureInPictureModeChanged
|
|
||||||
//+ onPictureInPictureUiStateChanged
|
|
||||||
//- onUserLeaveHint
|
|
||||||
|
|
||||||
override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {
|
|
||||||
super.onPictureInPictureUiStateChanged(pipState)
|
|
||||||
|
|
||||||
_pipModeState.value = true
|
|
||||||
|
|
||||||
/*
|
|
||||||
when {
|
|
||||||
// 进入画中画
|
|
||||||
pipState.isTransitioningToPip -> {}
|
|
||||||
// 收进边缘
|
|
||||||
pipState.isStashed -> {}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerPipActionReceiver() {
|
|
||||||
if (isPipActionReceiverRegistered) return
|
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
this,
|
|
||||||
pipActionReceiver,
|
|
||||||
PictureInPictureActionReceiver.createIntentFilter(),
|
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
||||||
)
|
|
||||||
isPipActionReceiverRegistered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterPipActionReceiver() {
|
|
||||||
if (!isPipActionReceiverRegistered) return
|
|
||||||
unregisterReceiver(pipActionReceiver)
|
|
||||||
isPipActionReceiverRegistered = false
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private var currentActivityRef: WeakReference<StreamActivity>? = null
|
private var currentActivityRef: WeakReference<StreamActivity>? = null
|
||||||
|
|
||||||
@@ -153,9 +92,8 @@ class StreamActivity: FragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun dismissActivePictureInPicture() {
|
fun dismissActivePictureInPicture() {
|
||||||
currentActivityRef?.get()
|
// 悬浮窗模式:不再使用系统 PiP,改为关闭悬浮窗
|
||||||
?.takeIf { it.isInPictureInPictureMode }
|
currentActivityRef?.get()?.floatingWindow?.hide()
|
||||||
?.finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
/** 视频码率(bps),0 表示使用默认值 */
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.github.miuzarte.scrcpyforandroid.R
|
import io.github.miuzarte.scrcpyforandroid.R
|
||||||
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.models.AdbLaunchParams
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.*
|
import io.github.miuzarte.scrcpyforandroid.services.*
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||||
@@ -512,6 +514,7 @@ internal class DeviceTabViewModel(
|
|||||||
autoStartScrcpy: Boolean = false,
|
autoStartScrcpy: Boolean = false,
|
||||||
autoEnterFullScreen: Boolean = false,
|
autoEnterFullScreen: Boolean = false,
|
||||||
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||||
|
scrcpyOptionsOverride: ((ClientOptions) -> ClientOptions)? = null,
|
||||||
) {
|
) {
|
||||||
val connected = connectionController.handleAdbConnected(host, port, scrcpyProfileId)
|
val connected = connectionController.handleAdbConnected(host, port, scrcpyProfileId)
|
||||||
val info = connected.info
|
val info = connected.info
|
||||||
@@ -551,7 +554,10 @@ internal class DeviceTabViewModel(
|
|||||||
|
|
||||||
if (autoStartScrcpy && sessionInfo.value == null) {
|
if (autoStartScrcpy && sessionInfo.value == null) {
|
||||||
runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) {
|
runBusy(EventLogMessage.Resource(R.string.vm_start_scrcpy)) {
|
||||||
startScrcpySession(openFullscreen = autoStartScrcpy && autoEnterFullScreen)
|
startScrcpySession(
|
||||||
|
openFullscreen = autoStartScrcpy && autoEnterFullScreen,
|
||||||
|
extraOptions = scrcpyOptionsOverride,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,13 +565,21 @@ internal class DeviceTabViewModel(
|
|||||||
suspend fun startScrcpySession(
|
suspend fun startScrcpySession(
|
||||||
openFullscreen: Boolean = false,
|
openFullscreen: Boolean = false,
|
||||||
startAppOverride: String? = null,
|
startAppOverride: String? = null,
|
||||||
|
extraOptions: ((ClientOptions) -> ClientOptions)? = null,
|
||||||
) {
|
) {
|
||||||
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value)
|
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId.value)
|
||||||
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
||||||
val resolvedOptions = startAppOverride
|
val resolvedOptions = extraOptions?.invoke(
|
||||||
?.takeIf { it.isNotBlank() }
|
startAppOverride
|
||||||
?.let { options.copy(startApp = it) }
|
?.takeIf { it.isNotBlank() }
|
||||||
?: options
|
?.let { options.copy(startApp = it) }
|
||||||
|
?: options
|
||||||
|
) ?: (
|
||||||
|
startAppOverride
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { options.copy(startApp = it) }
|
||||||
|
?: options
|
||||||
|
)
|
||||||
val session = scrcpy.start(resolvedOptions)
|
val session = scrcpy.start(resolvedOptions)
|
||||||
_pendingScrollToPreview.value = resolvedOptions.video && resolvedOptions.videoPlayback
|
_pendingScrollToPreview.value = resolvedOptions.video && resolvedOptions.videoPlayback
|
||||||
|
|
||||||
@@ -786,6 +800,52 @@ internal class DeviceTabViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理命令行启动参数:连接到指定设备并自动启动 scrcpy。
|
||||||
|
* 从 AppRuntime.adbLaunchParams 读取参数,用后即清。
|
||||||
|
*/
|
||||||
|
fun processLaunchParams() {
|
||||||
|
val params = AppRuntime.adbLaunchParams ?: return
|
||||||
|
AppRuntime.adbLaunchParams = null // consume
|
||||||
|
|
||||||
|
runAdbConnect(
|
||||||
|
label = EventLogMessage.Raw("Auto-connect"),
|
||||||
|
) {
|
||||||
|
disconnectCurrentTargetBeforeConnecting(params.host, params.port)
|
||||||
|
try {
|
||||||
|
connectWithTimeout(params.host, params.port)
|
||||||
|
handleAdbConnected(
|
||||||
|
host = params.host,
|
||||||
|
port = params.port,
|
||||||
|
autoStartScrcpy = true,
|
||||||
|
autoEnterFullScreen = params.fullscreen,
|
||||||
|
scrcpyOptionsOverride = { options ->
|
||||||
|
options.copy(
|
||||||
|
maxSize =
|
||||||
|
if (params.maxSize > 0) params.maxSize.toUShort() else options.maxSize,
|
||||||
|
videoBitRate =
|
||||||
|
if (params.videoBitRate > 0) params.videoBitRate else options.videoBitRate,
|
||||||
|
maxFps = params.maxFps.ifBlank { options.maxFps },
|
||||||
|
showTouches = params.showTouches,
|
||||||
|
fullscreen = params.fullscreen,
|
||||||
|
control = params.control,
|
||||||
|
video = params.video,
|
||||||
|
audio = params.audio,
|
||||||
|
turnScreenOff = params.turnScreenOff,
|
||||||
|
killAdbOnClose = params.killAdbOnClose,
|
||||||
|
startApp = params.startApp.ifBlank { options.startApp },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connectionController.updateQuickConnected(false)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
connectionController.markConnectionFailed(error)
|
||||||
|
logEvent(R.string.vm_adb_connection_failed, level = Log.ERROR, error = error)
|
||||||
|
AppRuntime.snackbar(R.string.vm_adb_connection_failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onDisconnectCurrent(target: ConnectionTarget?) {
|
fun onDisconnectCurrent(target: ConnectionTarget?) {
|
||||||
runAdbConnect(EventLogMessage.Resource(R.string.vm_disconnect_adb)) {
|
runAdbConnect(EventLogMessage.Resource(R.string.vm_disconnect_adb)) {
|
||||||
target?.let {
|
target?.let {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -662,7 +662,6 @@ fun MainScreen() {
|
|||||||
FullscreenControlRoute(
|
FullscreenControlRoute(
|
||||||
scrcpy = scrcpy,
|
scrcpy = scrcpy,
|
||||||
onBack = rootNavigator.pop,
|
onBack = rootNavigator.pop,
|
||||||
isInPip = false,
|
|
||||||
autoExitOnStop = true,
|
autoExitOnStop = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -0,0 +1,651 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.widgets
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.*
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 悬浮窗管理器,替代系统画中画(PiP)。
|
||||||
|
*
|
||||||
|
* 使用 WindowManager 创建 TYPE_APPLICATION_OVERLAY 悬浮窗口,
|
||||||
|
* 包含视频渲染(TextureView)、触摸事件处理和最小化控制按钮。
|
||||||
|
*
|
||||||
|
* 生命周期:
|
||||||
|
* - [show] 当 Activity 进入后台时调用,显示悬浮窗
|
||||||
|
* - [hide] 当 Activity 回到前台时调用,隐藏悬浮窗
|
||||||
|
* - [destroy] 彻底清理,释放所有资源
|
||||||
|
*/
|
||||||
|
class FloatingVideoWindow(private val context: Context) {
|
||||||
|
|
||||||
|
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private val metrics = context.resources.displayMetrics
|
||||||
|
|
||||||
|
private var windowView: FrameLayout? = null
|
||||||
|
private var textureView: TextureView? = null
|
||||||
|
private var controlCircle: View? = null
|
||||||
|
private var controlPanel: LinearLayout? = null
|
||||||
|
private var closeBtn: TextView? = null
|
||||||
|
private var fullscreenBtn: TextView? = null
|
||||||
|
private var currentSurface: android.view.Surface? = null
|
||||||
|
private var touchEventHandler: TouchEventHandler? = null
|
||||||
|
|
||||||
|
// 触摸状态持有者(独立于 Compose,但与 TouchEventHandler 使用相同类型)
|
||||||
|
private val activePointerIds = LinkedHashSet<Int>()
|
||||||
|
private val activePointerPositions = LinkedHashMap<Int, Offset>()
|
||||||
|
private val activePointerDevicePositions = LinkedHashMap<Int, Pair<Int, Int>>()
|
||||||
|
private val pointerLabels = LinkedHashMap<Int, Int>()
|
||||||
|
private var nextPointerLabel = 1
|
||||||
|
private var touchAreaSize = IntSize.Zero
|
||||||
|
|
||||||
|
private var isShowing = false
|
||||||
|
private var controlsVisible = false
|
||||||
|
private var sessionCollectJob: Job? = null
|
||||||
|
|
||||||
|
// 窗口位置
|
||||||
|
private var windowX: Int = 0
|
||||||
|
private var windowY: Int = 0
|
||||||
|
// 窗口尺寸(持久化,hide/show 之间保持)
|
||||||
|
private var windowWidth: Int = 0
|
||||||
|
private var windowHeight: Int = 0
|
||||||
|
|
||||||
|
// 拖动相关
|
||||||
|
private var dragStartX = 0f
|
||||||
|
private var dragStartY = 0f
|
||||||
|
private var windowStartX = 0
|
||||||
|
private var windowStartY = 0
|
||||||
|
private var isDragging = false
|
||||||
|
|
||||||
|
// 缩放相关
|
||||||
|
private var isResizing = false
|
||||||
|
private var resizeStartX = 0f
|
||||||
|
private var resizeStartY = 0f
|
||||||
|
private var resizeStartWidth = 0
|
||||||
|
private var resizeStartHeight = 0
|
||||||
|
// 视频宽高比(宽/高),缩放时锁定此比例
|
||||||
|
private var videoAspectRatio: Float = 9f / 16f
|
||||||
|
|
||||||
|
// 最小/最大窗口尺寸
|
||||||
|
private val minWindowWidth get() = dp(120)
|
||||||
|
private val minWindowHeight get() = dp(150)
|
||||||
|
|
||||||
|
private var onCloseCallback: (() -> Unit)? = null
|
||||||
|
private var onFullscreenCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FloatingVideoWindow"
|
||||||
|
private const val CONTROL_AUTO_HIDE_MS = 3000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查悬浮窗权限是否已授予。
|
||||||
|
*/
|
||||||
|
fun canDrawOverlays(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
Settings.canDrawOverlays(context)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开悬浮窗权限设置页面。
|
||||||
|
*/
|
||||||
|
fun requestOverlayPermission(activity: Activity, requestCode: Int) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||||
|
Uri.parse("package:${context.packageName}")
|
||||||
|
)
|
||||||
|
activity.startActivityForResult(intent, requestCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示悬浮窗。
|
||||||
|
* 在 Activity.onStop() 时调用。
|
||||||
|
*
|
||||||
|
* 窗口尺寸根据远程设备视频宽高比自适应计算,
|
||||||
|
* 确保悬浮窗保持与手机屏幕相同的比例。
|
||||||
|
*/
|
||||||
|
fun show(onClose: () -> Unit, onFullscreen: () -> Unit) {
|
||||||
|
if (isShowing) return
|
||||||
|
if (!canDrawOverlays()) {
|
||||||
|
Log.w(TAG, "show(): SYSTEM_ALERT_WINDOW permission not granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onCloseCallback = onClose
|
||||||
|
onFullscreenCallback = onFullscreen
|
||||||
|
|
||||||
|
createWindowView()
|
||||||
|
setupTouchHandler()
|
||||||
|
|
||||||
|
// 计算初始尺寸(首次显示时,根据视频比例计算;之后使用已调整的尺寸)
|
||||||
|
if (windowWidth == 0 || windowHeight == 0) {
|
||||||
|
val screenWidth = metrics.widthPixels
|
||||||
|
val screenHeight = metrics.heightPixels
|
||||||
|
|
||||||
|
// 从 session 获取远程设备视频尺寸,计算宽高比
|
||||||
|
val session = AppRuntime.scrcpy?.currentSessionState?.value
|
||||||
|
val videoAspect = if (session != null) {
|
||||||
|
session.width.toFloat() / session.height.toFloat().coerceAtLeast(1f)
|
||||||
|
} else {
|
||||||
|
9f / 16f
|
||||||
|
}
|
||||||
|
videoAspectRatio = videoAspect
|
||||||
|
|
||||||
|
val smallerDim = minOf(screenWidth, screenHeight)
|
||||||
|
windowWidth = (smallerDim * 0.35f).toInt().coerceIn(minWindowWidth, smallerDim / 2)
|
||||||
|
windowHeight = (windowWidth / videoAspect).toInt()
|
||||||
|
.coerceAtMost(screenHeight / 2).coerceAtLeast(minWindowHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算初始位置:右上角(首次或位置未设置时)
|
||||||
|
val screenWidth = metrics.widthPixels
|
||||||
|
if (windowX == 0 && windowY == 0) {
|
||||||
|
windowX = screenWidth - windowWidth - dp(16)
|
||||||
|
windowY = dp(80)
|
||||||
|
} else {
|
||||||
|
// 确保窗口不超出屏幕边界
|
||||||
|
windowX = windowX.coerceIn(0, screenWidth - windowWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
windowParams.apply {
|
||||||
|
x = windowX
|
||||||
|
y = windowY
|
||||||
|
width = windowWidth
|
||||||
|
height = windowHeight
|
||||||
|
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||||
|
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
||||||
|
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
windowManager.addView(windowView, windowParams)
|
||||||
|
isShowing = true
|
||||||
|
Log.i(TAG, "show(): window at ($windowX,$windowY) ${windowWidth}x${windowHeight}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "show(): failed to add window", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏悬浮窗。
|
||||||
|
* 在 Activity.onStart() 时调用。
|
||||||
|
*/
|
||||||
|
fun hide() {
|
||||||
|
if (!isShowing) return
|
||||||
|
Log.i(TAG, "hide(): removing window")
|
||||||
|
|
||||||
|
sessionCollectJob?.cancel()
|
||||||
|
sessionCollectJob = null
|
||||||
|
|
||||||
|
// 保存当前状态
|
||||||
|
windowView?.let {
|
||||||
|
windowX = windowParams.x
|
||||||
|
windowY = windowParams.y
|
||||||
|
windowWidth = windowParams.width
|
||||||
|
windowHeight = windowParams.height
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先分离 Surface
|
||||||
|
currentSurface?.let { surface ->
|
||||||
|
scope.launch {
|
||||||
|
NativeCoreFacade.detachVideoSurface(surface)
|
||||||
|
}
|
||||||
|
currentSurface = null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
windowView?.let { windowManager.removeView(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "hide(): failed to remove view", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
windowView = null
|
||||||
|
textureView = null
|
||||||
|
controlCircle = null
|
||||||
|
controlPanel = null
|
||||||
|
closeBtn = null
|
||||||
|
fullscreenBtn = null
|
||||||
|
touchEventHandler = null
|
||||||
|
isShowing = false
|
||||||
|
controlsVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 彻底销毁悬浮窗并清理资源。
|
||||||
|
*/
|
||||||
|
fun destroy() {
|
||||||
|
hide()
|
||||||
|
scope.cancel()
|
||||||
|
onCloseCallback = null
|
||||||
|
onFullscreenCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val windowParams = WindowManager.LayoutParams().apply {
|
||||||
|
type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
WindowManager.LayoutParams.TYPE_PHONE
|
||||||
|
}
|
||||||
|
format = PixelFormat.TRANSLUCENT
|
||||||
|
gravity = Gravity.TOP or Gravity.START
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
private fun createWindowView() {
|
||||||
|
val root = FrameLayout(context).apply {
|
||||||
|
clipChildren = false
|
||||||
|
clipToPadding = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TextureView 用于视频渲染 ---
|
||||||
|
val tv = TextureView(context).apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
// 触摸事件:通过 TouchEventHandler 注入到远程设备
|
||||||
|
setOnTouchListener { _, event ->
|
||||||
|
touchEventHandler?.handleMotionEvent(event) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||||
|
override fun onSurfaceTextureAvailable(
|
||||||
|
surface: SurfaceTexture,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
) {
|
||||||
|
touchAreaSize = IntSize(width, height)
|
||||||
|
val s = android.view.Surface(surface)
|
||||||
|
currentSurface = s
|
||||||
|
scope.launch {
|
||||||
|
NativeCoreFacade.attachVideoSurface(s)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "surfaceTextureAvailable: ${width}x${height}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureSizeChanged(
|
||||||
|
surface: SurfaceTexture,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
) {
|
||||||
|
touchAreaSize = IntSize(width, height)
|
||||||
|
// 更新 TouchEventHandler 的触摸区域
|
||||||
|
val sess = AppRuntime.scrcpy?.currentSessionState?.value
|
||||||
|
if (sess != null) {
|
||||||
|
touchEventHandler = buildTouchEventHandler(
|
||||||
|
sess,
|
||||||
|
IntSize(width, height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
||||||
|
currentSurface?.let { s ->
|
||||||
|
scope.launch {
|
||||||
|
NativeCoreFacade.detachVideoSurface(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSurface = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textureView = tv
|
||||||
|
root.addView(tv)
|
||||||
|
|
||||||
|
// --- 顶部拖拽条 ---
|
||||||
|
val dragBarHeight = dp(22)
|
||||||
|
val dragBar = View(context).apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
dragBarHeight,
|
||||||
|
).apply {
|
||||||
|
gravity = Gravity.TOP
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x00000000.toInt())
|
||||||
|
setOnTouchListener { _, event -> handleDragBarTouch(event) }
|
||||||
|
}
|
||||||
|
root.addView(dragBar)
|
||||||
|
|
||||||
|
// --- 右下角缩放手柄 ---
|
||||||
|
val handleSize = dp(26)
|
||||||
|
val resizeHandle = View(context).apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(handleSize, handleSize).apply {
|
||||||
|
gravity = Gravity.BOTTOM or Gravity.END
|
||||||
|
}
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadii = floatArrayOf(
|
||||||
|
0f, 0f, 0f, 0f, // tl, tr
|
||||||
|
dp(4).toFloat(), 0f, 0f, 0f, // bl, br
|
||||||
|
)
|
||||||
|
setColor(0x60FFFFFF.toInt())
|
||||||
|
}
|
||||||
|
setOnTouchListener { _, event -> handleResizeTouch(event) }
|
||||||
|
}
|
||||||
|
root.addView(resizeHandle)
|
||||||
|
|
||||||
|
// --- 圆圈控制按钮 ---
|
||||||
|
val circleSize = dp(36)
|
||||||
|
val circleBg = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(0x80000000.toInt())
|
||||||
|
setStroke(dp(1), 0x80FFFFFF.toInt())
|
||||||
|
}
|
||||||
|
val circle = View(context).apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize).apply {
|
||||||
|
gravity = Gravity.TOP or Gravity.END
|
||||||
|
topMargin = dp(8)
|
||||||
|
rightMargin = dp(8)
|
||||||
|
}
|
||||||
|
background = circleBg
|
||||||
|
setOnTouchListener { v, event ->
|
||||||
|
handleCircleTouch(v, event, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controlCircle = circle
|
||||||
|
root.addView(circle)
|
||||||
|
|
||||||
|
// --- 控制面板(关闭/全屏按钮)---
|
||||||
|
val panel = LinearLayout(context).apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
).apply {
|
||||||
|
gravity = Gravity.TOP or Gravity.END
|
||||||
|
topMargin = dp(8) + circleSize + dp(8)
|
||||||
|
rightMargin = dp(8)
|
||||||
|
}
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
visibility = View.GONE
|
||||||
|
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = dp(8).toFloat()
|
||||||
|
setColor(0xCC222222.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPanelButton(label: String, onClick: () -> Unit): TextView {
|
||||||
|
return TextView(context).apply {
|
||||||
|
text = label
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(0xFFFFFFFF.toInt())
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
setPadding(dp(16), dp(10), dp(16), dp(10))
|
||||||
|
setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBtn = createPanelButton("关闭") {
|
||||||
|
hide()
|
||||||
|
onCloseCallback?.invoke()
|
||||||
|
}
|
||||||
|
panel.addView(closeBtn)
|
||||||
|
|
||||||
|
// 分隔线
|
||||||
|
panel.addView(View(context).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
dp(1),
|
||||||
|
)
|
||||||
|
setBackgroundColor(0x40FFFFFF.toInt())
|
||||||
|
})
|
||||||
|
|
||||||
|
fullscreenBtn = createPanelButton("全屏") {
|
||||||
|
hide()
|
||||||
|
onFullscreenCallback?.invoke()
|
||||||
|
}
|
||||||
|
panel.addView(fullscreenBtn)
|
||||||
|
|
||||||
|
controlPanel = panel
|
||||||
|
root.addView(panel)
|
||||||
|
|
||||||
|
windowView = root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCircleTouch(v: View, event: MotionEvent, root: View): Boolean {
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
dragStartX = event.rawX
|
||||||
|
dragStartY = event.rawY
|
||||||
|
windowStartX = windowParams.x
|
||||||
|
windowStartY = windowParams.y
|
||||||
|
isDragging = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
val dx = event.rawX - dragStartX
|
||||||
|
val dy = event.rawY - dragStartY
|
||||||
|
if (kotlin.math.abs(dx) > dp(10).toFloat() || kotlin.math.abs(dy) > dp(10).toFloat()) {
|
||||||
|
isDragging = true
|
||||||
|
windowParams.x = (windowStartX + dx).toInt()
|
||||||
|
windowParams.y = (windowStartY + dy).toInt()
|
||||||
|
windowView?.let { windowManager.updateViewLayout(it, windowParams) }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (!isDragging) {
|
||||||
|
toggleControls()
|
||||||
|
}
|
||||||
|
saveWindowState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理拖拽条触摸事件,实现窗口拖动。
|
||||||
|
*/
|
||||||
|
private fun handleDragBarTouch(event: MotionEvent): Boolean {
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
dragStartX = event.rawX
|
||||||
|
dragStartY = event.rawY
|
||||||
|
windowStartX = windowParams.x
|
||||||
|
windowStartY = windowParams.y
|
||||||
|
isDragging = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
val dx = event.rawX - dragStartX
|
||||||
|
val dy = event.rawY - dragStartY
|
||||||
|
windowParams.x = (windowStartX + dx).toInt()
|
||||||
|
windowParams.y = (windowStartY + dy).toInt()
|
||||||
|
windowView?.let { windowManager.updateViewLayout(it, windowParams) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
saveWindowState()
|
||||||
|
isDragging = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理缩放手柄触摸事件,实现按视频宽高比等比缩放。
|
||||||
|
* 拖拽右下角手柄时,以宽度为基准,高度 = 宽度 ÷ 视频宽高比。
|
||||||
|
*/
|
||||||
|
private fun handleResizeTouch(event: MotionEvent): Boolean {
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
isResizing = true
|
||||||
|
resizeStartX = event.rawX
|
||||||
|
resizeStartY = event.rawY
|
||||||
|
resizeStartWidth = windowParams.width
|
||||||
|
resizeStartHeight = windowParams.height
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
if (!isResizing) return false
|
||||||
|
val dx = (event.rawX - resizeStartX).toInt()
|
||||||
|
// 以宽度为基准,按视频比例推导高度
|
||||||
|
var newWidth = (resizeStartWidth + dx)
|
||||||
|
.coerceIn(minWindowWidth, metrics.widthPixels)
|
||||||
|
var newHeight = (newWidth / videoAspectRatio).toInt()
|
||||||
|
|
||||||
|
// 如果推导的高度超出范围,则以高度为基准反推宽度
|
||||||
|
if (newHeight < minWindowHeight) {
|
||||||
|
newHeight = minWindowHeight
|
||||||
|
newWidth = (newHeight * videoAspectRatio).toInt()
|
||||||
|
} else if (newHeight > metrics.heightPixels) {
|
||||||
|
newHeight = metrics.heightPixels
|
||||||
|
newWidth = (newHeight * videoAspectRatio).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
windowParams.width = newWidth
|
||||||
|
windowParams.height = newHeight
|
||||||
|
windowView?.let { windowManager.updateViewLayout(it, windowParams) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
isResizing = false
|
||||||
|
saveWindowState()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将当前窗口状态保存到字段,以便 hide/show 周期中保持。
|
||||||
|
*/
|
||||||
|
private fun saveWindowState() {
|
||||||
|
windowX = windowParams.x
|
||||||
|
windowY = windowParams.y
|
||||||
|
windowWidth = windowParams.width
|
||||||
|
windowHeight = windowParams.height
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleControls() {
|
||||||
|
val panel = controlPanel ?: return
|
||||||
|
controlsVisible = !controlsVisible
|
||||||
|
panel.visibility = if (controlsVisible) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
if (controlsVisible) {
|
||||||
|
// 自动隐藏
|
||||||
|
scope.launch {
|
||||||
|
delay(CONTROL_AUTO_HIDE_MS)
|
||||||
|
if (controlsVisible) {
|
||||||
|
panel.visibility = View.GONE
|
||||||
|
controlsVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupTouchHandler() {
|
||||||
|
val scrcpy = AppRuntime.scrcpy ?: return
|
||||||
|
val session = scrcpy.currentSessionState.value ?: return
|
||||||
|
|
||||||
|
// 监听 session 变化以重建 TouchEventHandler
|
||||||
|
sessionCollectJob = scope.launch {
|
||||||
|
scrcpy.currentSessionState.collectLatest { sess ->
|
||||||
|
if (sess != null) {
|
||||||
|
touchEventHandler = buildTouchEventHandler(sess, touchAreaSize)
|
||||||
|
} else {
|
||||||
|
touchEventHandler = null
|
||||||
|
// session 结束,关闭悬浮窗
|
||||||
|
hide()
|
||||||
|
onCloseCallback?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 TouchEventHandler 实例。
|
||||||
|
* 与 FullscreenControlPage 中的创建方式一致,使用相同的状态持有者类型。
|
||||||
|
*/
|
||||||
|
private fun buildTouchEventHandler(
|
||||||
|
session: Scrcpy.Session.SessionInfo,
|
||||||
|
areaSize: IntSize,
|
||||||
|
): TouchEventHandler {
|
||||||
|
// 清除旧的触摸状态
|
||||||
|
resetTouchState()
|
||||||
|
|
||||||
|
return TouchEventHandler(
|
||||||
|
coroutineScope = scope,
|
||||||
|
session = session,
|
||||||
|
touchAreaSize = areaSize,
|
||||||
|
activePointerIds = activePointerIds,
|
||||||
|
activePointerPositions = activePointerPositions,
|
||||||
|
activePointerDevicePositions = activePointerDevicePositions,
|
||||||
|
pointerLabels = pointerLabels,
|
||||||
|
nextPointerLabel = nextPointerLabel,
|
||||||
|
mouseHoverEnabled = session.mouseHover,
|
||||||
|
onInjectTouch = { action, pointerId, x, y, pressure, actionButton, buttons ->
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
AppRuntime.scrcpy?.injectTouch(
|
||||||
|
action = action,
|
||||||
|
pointerId = pointerId,
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
screenWidth = session.width,
|
||||||
|
screenHeight = session.height,
|
||||||
|
pressure = pressure,
|
||||||
|
actionButton = actionButton,
|
||||||
|
buttons = buttons,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBackOrScreenOn = { action ->
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
AppRuntime.scrcpy?.pressBackOrTurnScreenOn(action)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActiveTouchCountChanged = {},
|
||||||
|
onActiveTouchDebugChanged = {},
|
||||||
|
onNextPointerLabelChanged = { nextPointerLabel = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetTouchState() {
|
||||||
|
activePointerIds.clear()
|
||||||
|
activePointerPositions.clear()
|
||||||
|
activePointerDevicePositions.clear()
|
||||||
|
pointerLabels.clear()
|
||||||
|
nextPointerLabel = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dp(value: Int): Int {
|
||||||
|
return (value * metrics.density + 0.5f).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
4
build_apk.bat
Normal file
4
build_apk.bat
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
powershell -ExecutionPolicy Bypass -File "%~dp0build_apk.ps1" %*
|
||||||
|
pause
|
||||||
61
build_apk.ps1
Normal file
61
build_apk.ps1
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
param(
|
||||||
|
[ValidateSet("debug", "release")]
|
||||||
|
[string]$BuildType = "debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
# JDK 配置
|
||||||
|
$JDK_HOME = "C:\Program Files\Eclipse Adoptium\jdk-11.0.31.11-hotspot"
|
||||||
|
$env:JAVA_HOME = $JDK_HOME
|
||||||
|
$env:PATH = "$JDK_HOME\bin;$env:PATH"
|
||||||
|
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " ScrcpyForAndroid APK Builder" -ForegroundColor Cyan
|
||||||
|
Write-Host " Build Type: $BuildType" -ForegroundColor Yellow
|
||||||
|
Write-Host " JDK: $JDK_HOME" -ForegroundColor Gray
|
||||||
|
Write-Host "========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Push-Location $ScriptDir
|
||||||
|
|
||||||
|
try {
|
||||||
|
# 停止旧 daemon(可能被 VS Code JRE 污染),失败也忽略
|
||||||
|
Write-Host ">>> Stopping old Gradle daemon..." -ForegroundColor Gray
|
||||||
|
.\gradlew.bat --stop 2>$null
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($BuildType -eq "release") {
|
||||||
|
Write-Host ">>> Building Release APK..." -ForegroundColor Green
|
||||||
|
.\gradlew.bat :app:assembleRelease --console=plain
|
||||||
|
$apkDir = "app\build\outputs\apk\release"
|
||||||
|
} else {
|
||||||
|
Write-Host ">>> Building Debug APK..." -ForegroundColor Green
|
||||||
|
.\gradlew.bat :app:assembleDebug --console=plain
|
||||||
|
$apkDir = "app\build\outputs\apk\debug"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host " BUILD SUCCESSFUL!" -ForegroundColor Green
|
||||||
|
Write-Host "========================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if (Test-Path $apkDir) {
|
||||||
|
Get-ChildItem "$apkDir\*.apk" | ForEach-Object {
|
||||||
|
$sizeMB = [math]::Round($_.Length / 1MB, 1)
|
||||||
|
Write-Host " $($_.Name) ($sizeMB MB)" -ForegroundColor White
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Output: $ScriptDir\$apkDir" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "BUILD FAILED (exit code: $LASTEXITCODE)" -ForegroundColor Red
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user