diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 0000000..9f2f4fa
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,218 @@
+name: Android Build and Release
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - "*"
+
+permissions:
+ contents: write
+
+jobs:
+ ci-build:
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: true
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: "21"
+ distribution: "temurin"
+ cache: gradle
+
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ !~/.gradle/caches/build-cache-*
+ key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: gradle-deps-
+
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches/build-cache-*
+ key: gradle-builds-${{ github.sha }}
+ restore-keys: gradle-builds-
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Enable full ABI matrix for CI build
+ run: |
+ perl -0777 -i -pe 's/abiFilters\.add\("armeabi-v7a"\)\s*abiFilters\.add\("arm64-v8a"\)/abiFilters.add("armeabi-v7a")\n abiFilters.add("arm64-v8a")\n abiFilters.add("x86")\n abiFilters.add("x86_64")/s' app/build.gradle.kts
+ perl -0777 -i -pe 's/include\("armeabi-v7a",\s*"arm64-v8a"\)/include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")/s' app/build.gradle.kts
+
+ - name: Build debug and unsigned release APKs
+ run: ./gradlew clean assembleDebug assembleRelease
+
+ - name: Collect CI APKs
+ run: |
+ set -euxo pipefail
+ mkdir -p out
+
+ collect_one() {
+ local src="$1"
+ local pattern="$2"
+ local out_name="$3"
+ local file
+ file=$(find "$src" -type f -name "*$pattern*.apk" | head -n1 || true)
+ if [ -n "$file" ]; then
+ cp "$file" "out/$out_name"
+ fi
+ }
+
+ collect_unsigned_release_one() {
+ local pattern="$1"
+ local out_name="$2"
+ local file
+ file=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*$pattern*.apk" | head -n1 || true)
+ if [ -n "$file" ]; then
+ cp "$file" "out/$out_name"
+ fi
+ }
+
+ collect_one app/build/outputs/apk/debug universal ci-debug-arm_all.apk
+ collect_one app/build/outputs/apk/debug arm64-v8a ci-debug-arm64.apk
+ collect_one app/build/outputs/apk/debug armeabi-v7a ci-debug-arm32.apk
+ collect_one app/build/outputs/apk/debug x86_64 ci-debug-x86_64.apk
+ x86_32_debug=$(find app/build/outputs/apk/debug -type f -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true)
+ if [ -n "$x86_32_debug" ]; then
+ cp "$x86_32_debug" out/ci-debug-x86_32.apk
+ fi
+
+ collect_unsigned_release_one universal ci-release-unsigned-arm_all.apk
+ collect_unsigned_release_one arm64-v8a ci-release-unsigned-arm64.apk
+ collect_unsigned_release_one armeabi-v7a ci-release-unsigned-arm32.apk
+ collect_unsigned_release_one x86_64 ci-release-unsigned-x86_64.apk
+ x86_32_release=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true)
+ if [ -n "$x86_32_release" ]; then
+ cp "$x86_32_release" out/ci-release-unsigned-x86_32.apk
+ fi
+
+ x86_all=$(find app/build/outputs/apk -type f \( -name "*x86_all*.apk" -o -name "*x86-all*.apk" -o -name "*x86universal*.apk" \) | head -n1 || true)
+ if [ -n "$x86_all" ]; then
+ cp "$x86_all" out/ci-release-unsigned-x86_all.apk
+ fi
+
+ ls -la out
+
+ - name: Upload CI artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-ci-apks-${{ github.run_number }}
+ path: out/*.apk
+ if-no-files-found: error
+
+ release-build:
+ if: ${{ startsWith(github.ref, 'refs/tags/') }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: true
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: "21"
+ distribution: "temurin"
+ cache: gradle
+
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ !~/.gradle/caches/build-cache-*
+ key: gradle-deps-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: gradle-deps-
+
+ - uses: actions/cache@v4
+ with:
+ path: |
+ ~/.gradle/caches/build-cache-*
+ key: gradle-builds-${{ github.sha }}
+ restore-keys: gradle-builds-
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Enable full ABI matrix for release build
+ run: |
+ perl -0777 -i -pe 's/abiFilters\.add\("armeabi-v7a"\)\s*abiFilters\.add\("arm64-v8a"\)/abiFilters.add("armeabi-v7a")\n abiFilters.add("arm64-v8a")\n abiFilters.add("x86")\n abiFilters.add("x86_64")/s' app/build.gradle.kts
+ perl -0777 -i -pe 's/include\("armeabi-v7a",\s*"arm64-v8a"\)/include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")/s' app/build.gradle.kts
+
+ - name: Build debug and unsigned release APKs
+ run: ./gradlew clean assembleDebug assembleRelease
+
+ - name: Collect release assets
+ run: |
+ set -euxo pipefail
+ TAG="${GITHUB_REF_NAME}"
+ REPO_NAME="${GITHUB_REPOSITORY##*/}"
+ mkdir -p dist
+
+ collect_one() {
+ local src="$1"
+ local pattern="$2"
+ local out_name="$3"
+ local file
+ file=$(find "$src" -type f -name "*$pattern*.apk" | head -n1 || true)
+ if [ -n "$file" ]; then
+ cp "$file" "dist/$out_name"
+ fi
+ }
+
+ collect_unsigned_release_one() {
+ local pattern="$1"
+ local out_name="$2"
+ local file
+ file=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*$pattern*.apk" | head -n1 || true)
+ if [ -n "$file" ]; then
+ cp "$file" "dist/$out_name"
+ fi
+ }
+
+ collect_one app/build/outputs/apk/debug universal "${REPO_NAME}-${TAG}-debug-arm_all.apk"
+ collect_one app/build/outputs/apk/debug arm64-v8a "${REPO_NAME}-${TAG}-debug-arm64.apk"
+ collect_one app/build/outputs/apk/debug armeabi-v7a "${REPO_NAME}-${TAG}-debug-arm32.apk"
+ collect_one app/build/outputs/apk/debug x86_64 "${REPO_NAME}-${TAG}-debug-x86_64.apk"
+ x86_32_debug=$(find app/build/outputs/apk/debug -type f -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true)
+ if [ -n "$x86_32_debug" ]; then
+ cp "$x86_32_debug" "dist/${REPO_NAME}-${TAG}-debug-x86_32.apk"
+ fi
+
+ collect_unsigned_release_one universal "${REPO_NAME}-${TAG}-release-unsigned-arm_all.apk"
+ collect_unsigned_release_one arm64-v8a "${REPO_NAME}-${TAG}-release-unsigned-arm64.apk"
+ collect_unsigned_release_one armeabi-v7a "${REPO_NAME}-${TAG}-release-unsigned-arm32.apk"
+ collect_unsigned_release_one x86_64 "${REPO_NAME}-${TAG}-release-unsigned-x86_64.apk"
+ x86_32_release=$(find app/build/outputs/apk/release -type f -name "*unsigned*.apk" -name "*x86*.apk" ! -name "*x86_64*" | head -n1 || true)
+ if [ -n "$x86_32_release" ]; then
+ cp "$x86_32_release" "dist/${REPO_NAME}-${TAG}-release-unsigned-x86_32.apk"
+ fi
+
+ x86_all=$(find app/build/outputs/apk -type f \( -name "*x86_all*.apk" -o -name "*x86-all*.apk" -o -name "*x86universal*.apk" \) | head -n1 || true)
+ if [ -n "$x86_all" ]; then
+ cp "$x86_all" "dist/${REPO_NAME}-${TAG}-release-unsigned-x86_all.apk"
+ fi
+
+ ls -la dist
+
+ - name: Publish GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: ${{ github.repository }} ${{ github.ref_name }}
+ generate_release_notes: true
+ files: dist/*.apk
diff --git a/ScrcpyForAndroid.code-workspace b/ScrcpyForAndroid.code-workspace
new file mode 100644
index 0000000..06010f7
--- /dev/null
+++ b/ScrcpyForAndroid.code-workspace
@@ -0,0 +1,19 @@
+{
+ "folders": [
+ {
+ "path": "."
+ },
+ {
+ "path": "../scrcpy"
+ },
+ {
+ "path": "../miuix"
+ },
+ {
+ "path": "../adblib"
+ },
+ {
+ "path": "../KernelSU"
+ }
+ ],
+}
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..c31a956
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,66 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace = "io.github.miuzarte.scrcpyforandroid"
+ compileSdk {
+ version = release(36) {
+ minorApiLevel = 1
+ }
+ }
+
+ defaultConfig {
+ applicationId = "io.github.miuzarte.scrcpyforandroid"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.0.1"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ buildFeatures {
+ compose = true
+ }
+ buildToolsVersion = "36.0.0"
+ ndkVersion = "28.2.13676358"
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.androidx.navigation3.runtime)
+ implementation(libs.material)
+ implementation(libs.miuix)
+ implementation(libs.miuix.icons)
+ implementation(libs.miuix.navigation3.ui)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/io/github/miuzarte/scrcpyforandroid/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/github/miuzarte/scrcpyforandroid/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..1be3e1b
--- /dev/null
+++ b/app/src/androidTest/java/io/github/miuzarte/scrcpyforandroid/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package io.github.miuzarte.scrcpyforandroid
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("io.github.miuzarte.scrcpyforandroid", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..758ec89
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/bin/scrcpy-server-v3.3.4 b/app/src/main/assets/bin/scrcpy-server-v3.3.4
new file mode 100644
index 0000000..89054b7
Binary files /dev/null and b/app/src/main/assets/bin/scrcpy-server-v3.3.4 differ
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt
new file mode 100644
index 0000000..c9fda86
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/MainActivity.kt
@@ -0,0 +1,18 @@
+package io.github.miuzarte.scrcpyforandroid
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import io.github.miuzarte.scrcpyforandroid.pages.MainPage
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ setContent {
+ MainPage()
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt
new file mode 100644
index 0000000..3161ab5
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt
@@ -0,0 +1,654 @@
+package io.github.miuzarte.scrcpyforandroid
+
+import android.content.Context
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.Surface
+import androidx.core.net.toUri
+import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
+import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
+import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
+import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpySessionManager
+import java.io.File
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import java.util.ArrayDeque
+import java.util.concurrent.CopyOnWriteArraySet
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Executors
+
+class NativeCoreFacade(private val appContext: Context) {
+
+ private val adbService = NativeAdbService(appContext)
+ private val sessionManager = ScrcpySessionManager(adbService)
+ private val executor = Executors.newSingleThreadExecutor()
+ private val surfaceMap = ConcurrentHashMap()
+ private val surfaceIdentityMap = ConcurrentHashMap()
+ private val decoderMap = ConcurrentHashMap()
+ 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()
+ private var packetCount: Long = 0
+ @Volatile private var audioPlayer: ScrcpyAudioPlayer? = null
+
+ @Volatile
+ private var currentSessionInfo: ScrcpySessionInfo? = null
+
+ fun close() {
+ releaseAllDecoders()
+ runCatching { sessionManager.stop() }
+ runCatching { adbService.close() }
+ executor.shutdown()
+ }
+
+ fun registerVideoSurface(tag: String, surface: Surface) {
+ val newId = System.identityHashCode(surface)
+ val oldId = surfaceIdentityMap[tag]
+ if (oldId != null && oldId == newId && decoderMap.containsKey(tag)) {
+ return
+ }
+ Log.i(TAG, "registerVideoSurface(): tag=$tag surfaceId=$newId oldSurfaceId=$oldId")
+ surfaceMap[tag] = surface
+ surfaceIdentityMap[tag] = newId
+ val session = currentSessionInfo ?: return
+ ensureVideoConsumerAttached()
+ val decoder = decoderMap[tag]
+ if (decoder != null) {
+ val switched = decoder.switchOutputSurface(surface)
+ Log.i(TAG, "registerVideoSurface(): switchOutputSurface tag=$tag success=$switched")
+ if (switched) {
+ return
+ }
+ }
+ createOrReplaceDecoder(tag, surface, session)
+ }
+
+ fun unregisterVideoSurface(tag: String, surface: Surface? = null) {
+ val currentId = surfaceIdentityMap[tag]
+ val requestId = surface?.let { System.identityHashCode(it) }
+ if (requestId != null && currentId != null && requestId != currentId) {
+ Log.i(TAG, "unregisterVideoSurface(): skip stale request tag=$tag requestSurfaceId=$requestId currentSurfaceId=$currentId")
+ return
+ }
+ Log.i(TAG, "unregisterVideoSurface(): tag=$tag surfaceId=$requestId")
+ surfaceMap.remove(tag)
+ surfaceIdentityMap.remove(tag)
+ if (currentSessionInfo == null) {
+ decoderMap.remove(tag)?.release()
+ }
+ }
+
+ fun adbPair(host: String, port: Int, pairingCode: String): Boolean {
+ return ioCall { adbService.pair(host, port, pairingCode) }
+ }
+
+ fun adbConnect(host: String, port: Int): Boolean = ioCall { adbService.connect(host, port) }
+
+ fun adbDisconnect(): Boolean {
+ ioCall { adbService.disconnect() }
+ return true
+ }
+
+ fun adbIsConnected(): Boolean = ioCall { adbService.isConnected() }
+
+ fun adbShell(command: String): String = ioCall { adbService.shell(command) }
+
+ fun setAdbKeyName(name: String) {
+ adbService.keyName = name
+ }
+
+ fun scrcpyStart(request: ScrcpyStartRequest): ScrcpySessionInfo {
+ return ioCall {
+ Log.i(TAG, "scrcpyStart(): request codec=${request.videoCodec} audio=${request.audio}")
+ val serverJar = if (request.customServerUri.isNullOrBlank()) {
+ extractAssetToCache(request.serverAsset)
+ } else {
+ extractUriToCache(request.customServerUri.toUri())
+ }
+
+ val info = sessionManager.start(
+ serverJar.toPath(),
+ ScrcpySessionManager.ScrcpyStartOptions(
+ serverVersion = request.serverVersion,
+ serverRemotePath = request.serverRemotePath,
+ video = !request.noVideo,
+ audio = request.audio,
+ control = !request.noControl,
+ maxSize = request.maxSize,
+ maxFps = request.maxFps,
+ videoBitRate = request.videoBitRate,
+ videoCodec = request.videoCodec,
+ audioBitRate = request.audioBitRate,
+ audioCodec = request.audioCodec,
+ videoEncoder = request.videoEncoder,
+ videoCodecOptions = request.videoCodecOptions,
+ audioEncoder = request.audioEncoder,
+ audioCodecOptions = request.audioCodecOptions,
+ audioDup = request.audioDup,
+ audioSource = request.audioSource,
+ videoSource = request.videoSource,
+ cameraId = request.cameraId,
+ cameraFacing = request.cameraFacing,
+ cameraSize = request.cameraSize,
+ cameraAr = request.cameraAr,
+ cameraFps = request.cameraFps,
+ cameraHighSpeed = request.cameraHighSpeed,
+ newDisplay = request.newDisplay,
+ displayId = request.displayId,
+ crop = request.crop,
+ ),
+ )
+ if (request.turnScreenOff) {
+ if (request.noControl) {
+ Log.w(TAG, "scrcpyStart(): turnScreenOff ignored because control is disabled")
+ } else {
+ runCatching { sessionManager.setDisplayPower(on = false) }
+ .onFailure { e -> Log.w(TAG, "scrcpyStart(): set display power failed", e) }
+ }
+ }
+ val session = ScrcpySessionInfo(
+ width = info.width,
+ height = info.height,
+ deviceName = info.deviceName,
+ codec = info.codecName,
+ controlEnabled = info.controlEnabled,
+ )
+ currentSessionInfo = session
+ releaseAllDecoders()
+ synchronized(bootstrapLock) {
+ bootstrapPackets.clear()
+ }
+ if (!request.noVideo) {
+ surfaceMap.forEach { (tag, surface) ->
+ Log.i(TAG, "scrcpyStart(): bind decoder to tag=$tag")
+ createOrReplaceDecoder(tag, surface, session)
+ }
+ }
+ packetCount = 0
+ if (!request.noVideo) {
+ ensureVideoConsumerAttached()
+ }
+
+ // Audio player
+ audioPlayer?.release()
+ audioPlayer = null
+ if (info.audioCodecId != 0 && !request.noAudioPlayback) {
+ Log.i(TAG, "scrcpyStart(): create audio player codecId=0x${info.audioCodecId.toUInt().toString(16)}")
+ val player = ScrcpyAudioPlayer(info.audioCodecId)
+ audioPlayer = player
+ sessionManager.attachAudioConsumer { packet ->
+ player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)
+ }
+ } else {
+ Log.i(TAG, "scrcpyStart(): audio playback disabled for this session")
+ }
+
+ session
+ }
+ }
+
+ fun scrcpyStop(): Boolean {
+ ioCall {
+ releaseAllDecoders()
+ synchronized(bootstrapLock) {
+ bootstrapPackets.clear()
+ }
+ currentSessionInfo = null
+ sessionManager.clearVideoConsumer()
+ sessionManager.clearAudioConsumer()
+ sessionManager.stop()
+ audioPlayer?.release()
+ audioPlayer = null
+ }
+ return true
+ }
+
+ fun scrcpyListEncoders(customServerUri: String?, remotePath: String, serverVersion: String = "3.3.4"): ScrcpyEncoderLists {
+ return ioCall {
+ val serverJar = if (customServerUri.isNullOrBlank()) {
+ extractAssetToCache(DEFAULT_SERVER_ASSET)
+ } else {
+ extractUriToCache(customServerUri.toUri())
+ }
+ val result = sessionManager.listEncoders(
+ serverJarPath = serverJar.toPath(),
+ options = ScrcpySessionManager.ScrcpyStartOptions(
+ serverVersion = serverVersion,
+ serverRemotePath = remotePath,
+ ),
+ )
+ ScrcpyEncoderLists(
+ videoEncoders = result.videoEncoders,
+ audioEncoders = result.audioEncoders,
+ videoEncoderTypes = result.videoEncoderTypes,
+ audioEncoderTypes = result.audioEncoderTypes,
+ rawOutput = result.rawOutput,
+ )
+ }
+ }
+
+ fun scrcpyListCameraSizes(customServerUri: String?, remotePath: String, serverVersion: String = "3.3.4"): ScrcpyCameraSizeLists {
+ return ioCall {
+ val serverJar = if (customServerUri.isNullOrBlank()) {
+ extractAssetToCache(DEFAULT_SERVER_ASSET)
+ } else {
+ extractUriToCache(customServerUri.toUri())
+ }
+ val result = sessionManager.listCameraSizes(
+ serverJarPath = serverJar.toPath(),
+ options = ScrcpySessionManager.ScrcpyStartOptions(
+ serverVersion = serverVersion,
+ serverRemotePath = remotePath,
+ ),
+ )
+ ScrcpyCameraSizeLists(
+ sizes = result.sizes,
+ rawOutput = result.rawOutput,
+ )
+ }
+ }
+
+ fun scrcpyIsStarted(): Boolean = ioCall { sessionManager.isStarted() }
+
+ fun getLastScrcpyServerCommand(): String? = ioCall { sessionManager.getLastServerCommand() }
+
+ fun scrcpyInjectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
+ ioExecute {
+ runCatching { sessionManager.injectKeycode(action, keycode, repeat, metaState) }
+ }
+ }
+
+ fun scrcpyInjectText(text: String) {
+ ioExecute {
+ runCatching { sessionManager.injectText(text) }
+ }
+ }
+
+ fun scrcpyInjectTouch(
+ action: Int,
+ x: Int,
+ y: Int,
+ screenWidth: Int,
+ screenHeight: Int,
+ pressure: Float,
+ pointerId: Long = 0L,
+ actionButton: Int = 1,
+ buttons: Int = 1,
+ ) {
+ ioExecute {
+ runCatching {
+ sessionManager.injectTouch(
+ action = action,
+ pointerId = pointerId,
+ x = x,
+ y = y,
+ screenWidth = screenWidth,
+ screenHeight = screenHeight,
+ pressure = pressure,
+ actionButton = actionButton,
+ buttons = buttons,
+ )
+ }
+ }
+ }
+
+ fun scrcpyInjectScroll(
+ x: Int,
+ y: Int,
+ screenWidth: Int,
+ screenHeight: Int,
+ hScroll: Float,
+ vScroll: Float,
+ buttons: Int = 0,
+ ) {
+ ioExecute {
+ runCatching { sessionManager.injectScroll(x, y, screenWidth, screenHeight, hScroll, vScroll, buttons) }
+ }
+ }
+
+ 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)
+ }
+
+ fun scrcpyBackOrScreenOn(action: Int = 0) {
+ ioExecute {
+ runCatching { sessionManager.pressBackOrScreenOn(action) }
+ }
+ }
+
+ private fun extractAssetToCache(assetPath: String): File {
+ val clean = assetPath.removePrefix("/")
+ val source = appContext.assets.open(clean)
+ val outputFile = File(appContext.cacheDir, File(clean).name)
+ source.use { input ->
+ outputFile.outputStream().use { output -> input.copyTo(output) }
+ }
+ return outputFile
+ }
+
+ private fun extractUriToCache(uri: Uri): File {
+ val fileName = "custom-scrcpy-server.jar"
+ val outputFile = File(appContext.cacheDir, fileName)
+ appContext.contentResolver.openInputStream(uri).use { input ->
+ requireNotNull(input) { "Unable to open selected server URI" }
+ outputFile.outputStream().use { output -> input.copyTo(output) }
+ }
+ return outputFile
+ }
+
+ companion object {
+ private const val TAG = "NativeCoreFacade"
+ private const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
+ private const val MAX_BOOTSTRAP_PACKETS = 90
+
+ @Volatile
+ private var instance: NativeCoreFacade? = null
+
+ fun get(context: Context): NativeCoreFacade {
+ return instance ?: synchronized(this) {
+ instance ?: NativeCoreFacade(context.applicationContext).also { instance = it }
+ }
+ }
+
+ fun defaultStartRequest(
+ customServerUri: String?,
+ maxSize: Int,
+ videoBitRate: Int,
+ remotePath: String,
+ videoCodec: String = "h264",
+ audio: Boolean = true,
+ audioCodec: String = "opus",
+ audioBitRate: Int = 128_000,
+ maxFps: Float = 0f,
+ noControl: Boolean = false,
+ videoEncoder: String = "",
+ videoCodecOptions: String = "",
+ audioEncoder: String = "",
+ audioCodecOptions: String = "",
+ audioDup: Boolean = false,
+ audioSource: String = "",
+ videoSource: String = "display",
+ cameraId: String = "",
+ cameraFacing: String = "",
+ cameraSize: String = "",
+ cameraAr: String = "",
+ cameraFps: Int = 0,
+ cameraHighSpeed: Boolean = false,
+ noAudioPlayback: Boolean = false,
+ requireAudio: Boolean = false,
+ turnScreenOff: Boolean = false,
+ noVideo: Boolean = false,
+ newDisplay: String = "",
+ displayId: Int? = null,
+ crop: String = "",
+ ): ScrcpyStartRequest {
+ return ScrcpyStartRequest(
+ serverAsset = DEFAULT_SERVER_ASSET,
+ customServerUri = customServerUri,
+ serverVersion = "3.3.4",
+ serverRemotePath = remotePath,
+ maxSize = maxSize,
+ videoBitRate = videoBitRate,
+ videoCodec = videoCodec,
+ audio = audio,
+ audioCodec = audioCodec,
+ audioBitRate = audioBitRate,
+ maxFps = maxFps,
+ noControl = noControl,
+ videoEncoder = videoEncoder,
+ videoCodecOptions = videoCodecOptions,
+ audioEncoder = audioEncoder,
+ audioCodecOptions = audioCodecOptions,
+ audioDup = audioDup,
+ audioSource = audioSource,
+ videoSource = videoSource,
+ cameraId = cameraId,
+ cameraFacing = cameraFacing,
+ cameraSize = cameraSize,
+ cameraAr = cameraAr,
+ cameraFps = cameraFps,
+ cameraHighSpeed = cameraHighSpeed,
+ noAudioPlayback = noAudioPlayback,
+ requireAudio = requireAudio,
+ turnScreenOff = turnScreenOff,
+ noVideo = noVideo,
+ newDisplay = newDisplay,
+ displayId = displayId,
+ crop = crop,
+ )
+ }
+
+ fun nowLogPrefix(): String {
+ val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
+ return "[$stamp]"
+ }
+ }
+
+ 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
+ }
+ }
+
+ private fun createOrReplaceDecoder(tag: String, surface: Surface, session: ScrcpySessionInfo) {
+ decoderMap.remove(tag)?.release()
+ val mime = when (session.codec.lowercase()) {
+ "h264" -> "video/avc"
+ "h265" -> "video/hevc"
+ "av1" -> "video/av01"
+ else -> "video/avc"
+ }
+ Log.i(TAG, "createOrReplaceDecoder(): tag=$tag codec=$mime size=${session.width}x${session.height}")
+ val decoder = AnnexBDecoder(
+ 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@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) }
+ }
+ }
+ },
+ )
+ decoderMap[tag] = decoder
+ replayBootstrapPackets(decoder)
+ }
+
+ private fun replayBootstrapPackets(decoder: AnnexBDecoder) {
+ val snapshot = synchronized(bootstrapLock) { bootstrapPackets.toList() }
+ if (snapshot.isEmpty()) {
+ return
+ }
+ Log.i(TAG, "replayBootstrapPackets(): count=${snapshot.size}")
+ snapshot.forEach { packet ->
+ runCatching {
+ decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
+ }
+ }
+ }
+
+ private fun cacheBootstrapPacket(packet: ScrcpySessionManager.VideoPacket) {
+ val cached = CachedPacket(
+ data = packet.data.copyOf(),
+ ptsUs = packet.ptsUs,
+ isConfig = packet.isConfig,
+ isKeyFrame = packet.isKeyFrame,
+ )
+ synchronized(bootstrapLock) {
+ while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) {
+ bootstrapPackets.removeFirst()
+ }
+ bootstrapPackets.addLast(cached)
+ }
+ }
+
+ private fun ensureVideoConsumerAttached() {
+ sessionManager.attachVideoConsumer { packet ->
+ cacheBootstrapPacket(packet)
+ packetCount += 1
+ if (packetCount == 1L || packetCount % 120L == 0L) {
+ Log.i(TAG, "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoders=${decoderMap.size}")
+ }
+ decoderMap.forEach { (tag, decoder) ->
+ if (!surfaceIdentityMap.containsKey(tag)) {
+ return@forEach
+ }
+ runCatching {
+ decoder.feedAnnexB(packet.data, packet.ptsUs, packet.isKeyFrame, packet.isConfig)
+ }
+ }
+ }
+ }
+
+ private fun releaseAllDecoders() {
+ decoderMap.values.forEach { decoder ->
+ runCatching { decoder.release() }
+ }
+ decoderMap.clear()
+ }
+
+ private fun ioCall(task: () -> T): T {
+ return try {
+ executor.submit { task() }.get()
+ } catch (e: ExecutionException) {
+ val cause = e.cause
+ if (cause is Exception) {
+ throw cause
+ }
+ throw RuntimeException(cause ?: e)
+ }
+ }
+
+ private fun ioExecute(task: () -> Unit) {
+ executor.execute(task)
+ }
+}
+
+data class ScrcpyStartRequest(
+ val serverAsset: String,
+ val customServerUri: String?,
+ val serverVersion: String,
+ val serverRemotePath: String,
+ val maxSize: Int,
+ val videoBitRate: Int,
+ val videoCodec: String = "h264",
+ val audio: Boolean = true,
+ val audioCodec: String = "opus",
+ val audioBitRate: Int = 128_000,
+ val maxFps: Float = 0f,
+ val noControl: Boolean = false,
+ val videoEncoder: String = "",
+ val videoCodecOptions: String = "",
+ val audioEncoder: String = "",
+ val audioCodecOptions: String = "",
+ val audioDup: Boolean = false,
+ val audioSource: String = "",
+ val videoSource: String = "display",
+ val cameraId: String = "",
+ val cameraFacing: String = "",
+ val cameraSize: String = "",
+ val cameraAr: String = "",
+ val cameraFps: Int = 0,
+ val cameraHighSpeed: Boolean = false,
+ val noAudioPlayback: Boolean = false,
+ val requireAudio: Boolean = false,
+ val turnScreenOff: Boolean = false,
+ val noVideo: Boolean = false,
+ val newDisplay: String = "",
+ val displayId: Int? = null,
+ val crop: String = "",
+)
+
+data class ScrcpyEncoderLists(
+ val videoEncoders: List,
+ val audioEncoders: List,
+ val videoEncoderTypes: Map = emptyMap(),
+ val audioEncoderTypes: Map = emptyMap(),
+ val rawOutput: String = "",
+)
+
+data class ScrcpyCameraSizeLists(
+ val sizes: List,
+ val rawOutput: String = "",
+)
+
+data class ScrcpySessionInfo(
+ val width: Int,
+ val height: Int,
+ val deviceName: String,
+ val codec: String,
+ val controlEnabled: Boolean,
+)
+
+object AndroidKeycodes {
+ const val HOME = 3
+ const val BACK = 4
+ const val POWER = 26
+ const val APP_SWITCH = 187
+ const val VOLUME_UP = 24
+ const val VOLUME_DOWN = 25
+}
+
+object MotionActions {
+ const val DOWN = 0
+ const val UP = 1
+ const val MOVE = 2
+ const val CANCEL = 3
+ const val POINTER_DOWN = 5
+ const val POINTER_UP = 6
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt
new file mode 100644
index 0000000..571f79d
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppDefaults.kt
@@ -0,0 +1,74 @@
+package io.github.miuzarte.scrcpyforandroid.constants
+
+object AppDefaults {
+ const val MaxEventLogLines = 512
+ const val DefaultAdbPort = 5555
+
+ // Devices
+ const val DefaultQuickConnectInput = ""
+
+ const val DefaultPairHost = ""
+ const val DefaultServerRemotePathInput = ""
+ const val DefaultAdbKeyNameInput = ""
+ const val DefaultPairPort = ""
+ const val DefaultPairCode = ""
+
+ const val DefaultAudioEnabled = true
+ const val DefaultAudioCodec = "opus"
+ const val DefaultAudioBitRateKbps = 128
+ const val DefaultVideoCodec = "h264"
+ const val DefaultBitRateMbps = 8f
+ const val DefaultBitRateInput = "8.0"
+
+ const val DefaultTurnScreenOff = false
+ const val DefaultNoControl = false
+ const val DefaultNoVideo = false
+
+ const val DefaultVideoSourcePreset = "display"
+ const val DefaultDisplayIdInput = ""
+
+ const val DefaultCameraIdInput = ""
+ const val DefaultCameraFacingPreset = ""
+ const val DefaultCameraSizePreset = ""
+ const val DefaultCameraSizeCustom = ""
+ const val DefaultCameraArInput = ""
+ const val DefaultCameraFpsInput = ""
+ const val DefaultCameraHighSpeed = false
+
+ const val DefaultAudioSourcePreset = "output"
+ const val DefaultAudioSourceCustom = ""
+ const val DefaultAudioDup = false
+ const val DefaultNoAudioPlayback = false
+ const val DefaultRequireAudio = false
+
+ const val DefaultMaxSizeInput = ""
+ const val DefaultMaxFpsInput = ""
+
+ const val DefaultVideoEncoder = ""
+ const val DefaultVideoCodecOptions = ""
+ const val DefaultAudioEncoder = ""
+ const val DefaultAudioCodecOptions = ""
+
+ const val DefaultNewDisplayWidth = ""
+ const val DefaultNewDisplayHeight = ""
+ const val DefaultNewDisplayDpi = ""
+
+ const val DefaultCropWidth = ""
+ const val DefaultCropHeight = ""
+ const val DefaultCropX = ""
+ const val DefaultCropY = ""
+
+ // Settings
+ const val DefaultThemeBaseIndex = 0
+ const val DefaultMonetEnabled = false
+
+ const val DefaultFullscreenDebugInfoEnabled = false
+ const val DefaultDevicePreviewCardHeightDp = 320
+ const val DefaultKeepScreenOnWhenStreamingEnabled = false
+ const val DefaultVirtualButtonsOutside = "more,home,back"
+ const val DefaultVirtualButtonsInMore = "app_switch,menu,notification,volume_up,volume_down,volume_mute,power,screenshot"
+
+ const val DefaultServerRemotePath = "/data/local/tmp/scrcpy-server.jar"
+
+ const val DefaultAdbKeyName = "scrcpy"
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt
new file mode 100644
index 0000000..7e5161d
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/AppPreferenceKeys.kt
@@ -0,0 +1,58 @@
+package io.github.miuzarte.scrcpyforandroid.constants
+
+object AppPreferenceKeys {
+ const val PrefsName = "scrcpy_app_prefs"
+ const val NativeAdbKeyPrefsName = "nativecore_adb_rsa"
+ const val NativeAdbPrivateKey = "priv"
+ const val ThemeBaseIndex = "theme_base_index"
+ const val MonetEnabled = "monet_enabled"
+ const val FullscreenDebugInfoEnabled = "fullscreen_debug_info_enabled"
+ const val DevicePreviewCardHeightDp = "device_preview_card_height_dp"
+ const val KeepScreenOnWhenStreamingEnabled = "keep_screen_on_when_streaming_enabled"
+ const val VirtualButtonsOutside = "virtual_buttons_outside"
+ const val VirtualButtonsInMore = "virtual_buttons_in_more"
+ const val CustomServerUri = "custom_server_uri"
+ const val ServerRemotePath = "server_remote_path"
+ const val VideoCodec = "video_codec"
+ const val AudioEnabled = "audio_enabled"
+ const val AudioCodec = "audio_codec"
+ const val PairHost = "pair_host"
+ const val PairPort = "pair_port"
+ const val PairCode = "pair_code"
+ const val QuickConnectInput = "quick_connect_input"
+ const val BitRateMbps = "bit_rate_mbps"
+ const val BitRateInput = "bit_rate_input"
+ const val AudioBitRateKbps = "audio_bit_rate_kbps"
+ const val MaxSizeInput = "max_size_input"
+ const val MaxFpsInput = "max_fps_input"
+ const val NoControl = "no_control"
+ const val VideoEncoder = "video_encoder"
+ const val VideoCodecOptions = "video_codec_options"
+ const val AudioEncoder = "audio_encoder"
+ const val AudioCodecOptions = "audio_codec_options"
+ const val AudioDup = "audio_dup"
+ const val AudioSourcePreset = "audio_source_preset"
+ const val AudioSourceCustom = "audio_source_custom"
+ const val VideoSourcePreset = "video_source_preset"
+ const val CameraIdInput = "camera_id_input"
+ const val CameraFacingPreset = "camera_facing_preset"
+ const val CameraSizePreset = "camera_size_preset"
+ const val CameraSizeCustom = "camera_size_custom"
+ const val CameraArInput = "camera_ar_input"
+ const val CameraFpsInput = "camera_fps_input"
+ const val CameraHighSpeed = "camera_high_speed"
+ const val NoAudioPlayback = "no_audio_playback"
+ const val NoVideo = "no_video"
+ const val RequireAudio = "require_audio"
+ const val TurnScreenOff = "turn_screen_off"
+ const val NewDisplayWidth = "new_display_width"
+ const val NewDisplayHeight = "new_display_height"
+ const val NewDisplayDpi = "new_display_dpi"
+ const val DisplayIdInput = "display_id_input"
+ const val CropWidth = "crop_width"
+ const val CropHeight = "crop_height"
+ const val CropX = "crop_x"
+ const val CropY = "crop_y"
+ const val AdbKeyName = "adb_key_name"
+ const val QuickDevices = "quick_devices"
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt
new file mode 100644
index 0000000..daa7025
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt
@@ -0,0 +1,7 @@
+package io.github.miuzarte.scrcpyforandroid.constants
+
+object ScrcpyPresets {
+ val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)
+ val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120)
+ val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt
new file mode 100644
index 0000000..b3f2653
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiAndroidKeycodes.kt
@@ -0,0 +1,14 @@
+package io.github.miuzarte.scrcpyforandroid.constants
+
+internal object UiAndroidKeycodes {
+ const val HOME = 3
+ const val BACK = 4
+ const val POWER = 26
+ const val VOLUME_UP = 24
+ const val VOLUME_DOWN = 25
+ const val VOLUME_MUTE = 164
+ const val MENU = 82
+ const val NOTIFICATION = 83
+ const val SYSRQ = 120
+ const val APP_SWITCH = 187
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt
new file mode 100644
index 0000000..2991b2d
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiMotion.kt
@@ -0,0 +1,6 @@
+package io.github.miuzarte.scrcpyforandroid.constants
+
+object UiMotion {
+ const val PageSwitchDampingRatio = 0.9f
+ const val PageSwitchStiffness = 700f
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt
new file mode 100644
index 0000000..4337411
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt
@@ -0,0 +1,23 @@
+package io.github.miuzarte.scrcpyforandroid.constants
+
+import androidx.compose.ui.unit.dp
+
+object UiSpacing {
+ val Tiny = 2.dp
+ val Small = 4.dp
+ val Medium = 8.dp
+ val PageItem = 12.dp
+ val Large = 16.dp
+ val PopupHorizontal = 20.dp
+ val PageHorizontal = 12.dp
+ val PageVertical = 12.dp
+ val SectionTitleLeadingGap = 8.dp
+ val SectionTitleStart = 12.dp
+ val SectionTitleTop = 12.dp
+ val SectionTitleBottom = 6.dp
+ val FieldLabelBottom = 4.dp
+ val CardContent = 12.dp
+ val CardTitle = 16.dp
+ val BottomContent = 64.dp
+ val BottomSheetBottom = 16.dp
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt
new file mode 100644
index 0000000..dc28085
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/haptics/AppHaptics.kt
@@ -0,0 +1,32 @@
+package io.github.miuzarte.scrcpyforandroid.haptics
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+
+@Immutable
+data class AppHaptics(
+ val press: () -> Unit,
+ val confirm: () -> Unit,
+)
+
+@Composable
+fun rememberAppHaptics(): AppHaptics {
+ val hapticFeedback = LocalHapticFeedback.current
+ val pressAction = rememberUpdatedState {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
+ }
+ val confirmAction = rememberUpdatedState {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
+ }
+
+ return remember {
+ AppHaptics(
+ press = { pressAction.value.invoke() },
+ confirm = { confirmAction.value.invoke() },
+ )
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt
new file mode 100644
index 0000000..6d0fdd1
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt
@@ -0,0 +1,14 @@
+package io.github.miuzarte.scrcpyforandroid.models
+
+internal data class ConnectionTarget(
+ val host: String,
+ val port: Int,
+)
+
+internal data class DeviceShortcut(
+ val id: String,
+ val name: String,
+ val host: String,
+ val port: Int,
+ val online: Boolean,
+)
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt
new file mode 100644
index 0000000..0f9f415
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/AnnexBDecoder.kt
@@ -0,0 +1,161 @@
+package io.github.miuzarte.scrcpyforandroid.nativecore
+
+import android.media.MediaCodec
+import android.media.MediaFormat
+import android.util.Log
+import android.view.Surface
+
+class AnnexBDecoder(
+ width: Int,
+ height: Int,
+ outputSurface: Surface,
+ mimeType: String = MIME_AVC,
+ sps: ByteArray? = null,
+ pps: ByteArray? = null,
+ onOutputSizeChanged: ((width: Int, height: Int) -> Unit)? = null,
+ onFpsUpdated: ((fps: Float) -> Unit)? = null,
+) {
+ private val codec: MediaCodec = MediaCodec.createDecoderByType(mimeType)
+ private val bufferInfo = MediaCodec.BufferInfo()
+ private val outputSizeCallback = onOutputSizeChanged
+ private val fpsUpdatedCallback = onFpsUpdated
+ private val decoderMime = mimeType
+ private var inCount = 0L
+ private var outCount = 0L
+ private var fpsWindowStartNs = System.nanoTime()
+ private var fpsWindowFrameCount = 0
+ @Volatile
+ private var released = false
+
+ init {
+ val format = MediaFormat.createVideoFormat(mimeType, width, height)
+ if (sps != null) {
+ format.setByteBuffer("csd-0", java.nio.ByteBuffer.wrap(sps))
+ }
+ if (pps != null) {
+ format.setByteBuffer("csd-1", java.nio.ByteBuffer.wrap(pps))
+ }
+ codec.configure(format, outputSurface, null, 0)
+ codec.start()
+ }
+
+ @Synchronized
+ fun feedAnnexB(data: ByteArray, ptsUs: Long, isKeyFrame: Boolean, isConfig: Boolean = false) {
+ if (released) {
+ return
+ }
+ runCatching {
+ var inputIndex = codec.dequeueInputBuffer(INPUT_TIMEOUT_US)
+ if (inputIndex < 0 && (isConfig || isKeyFrame)) {
+ // Retry for critical packets to reduce startup stalls on av1/hevc.
+ drainOutput()
+ inputIndex = codec.dequeueInputBuffer(CRITICAL_INPUT_TIMEOUT_US)
+ }
+ if (inputIndex >= 0) {
+ val inBuf = codec.getInputBuffer(inputIndex)
+ inBuf?.clear()
+ inBuf?.put(data)
+ var flags = 0
+ if (isKeyFrame) {
+ flags = flags or MediaCodec.BUFFER_FLAG_KEY_FRAME
+ }
+ if (isConfig) {
+ flags = flags or MediaCodec.BUFFER_FLAG_CODEC_CONFIG
+ }
+ codec.queueInputBuffer(inputIndex, 0, data.size, ptsUs, flags)
+ inCount += 1
+ if (inCount == 1L || inCount % 180L == 0L || isConfig) {
+ Log.i(TAG, "feed(): mime=$decoderMime in=$inCount size=${data.size} key=$isKeyFrame cfg=$isConfig pts=$ptsUs")
+ }
+ } else {
+ if (isConfig || isKeyFrame) {
+ Log.w(TAG, "drop critical packet: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig")
+ }
+ }
+ drainOutput()
+ }.onFailure {
+ Log.w(TAG, "feed failed: mime=$decoderMime size=${data.size} key=$isKeyFrame cfg=$isConfig", it)
+ }
+ }
+
+ @Synchronized
+ fun switchOutputSurface(surface: Surface): Boolean {
+ if (released) {
+ return false
+ }
+ return runCatching {
+ codec.setOutputSurface(surface)
+ true
+ }.getOrElse { false }
+ }
+
+ @Synchronized
+ fun release() {
+ released = true
+ runCatching { codec.stop() }
+ runCatching { codec.release() }
+ }
+
+ private fun drainOutput() {
+ while (true) {
+ val outIndex = codec.dequeueOutputBuffer(bufferInfo, OUTPUT_TIMEOUT_US)
+ when {
+ outIndex >= 0 -> {
+ codec.releaseOutputBuffer(outIndex, true)
+ outCount += 1
+ recordOutputFrame()
+ if (outCount == 1L || outCount % 180L == 0L) {
+ Log.i(TAG, "drain(): mime=$decoderMime out=$outCount")
+ }
+ }
+ outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
+ notifyOutputSize(codec.outputFormat)
+ continue
+ }
+ else -> return
+ }
+ }
+ }
+
+ private fun notifyOutputSize(format: MediaFormat) {
+ val rawWidth = format.getInteger(MediaFormat.KEY_WIDTH)
+ val rawHeight = format.getInteger(MediaFormat.KEY_HEIGHT)
+
+ val width = if (format.containsKey("crop-right") && format.containsKey("crop-left")) {
+ format.getInteger("crop-right") - format.getInteger("crop-left") + 1
+ } else {
+ rawWidth
+ }
+ val height = if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) {
+ format.getInteger("crop-bottom") - format.getInteger("crop-top") + 1
+ } else {
+ rawHeight
+ }
+
+ if (width > 0 && height > 0) {
+ outputSizeCallback?.invoke(width, height)
+ }
+ }
+
+ private fun recordOutputFrame() {
+ fpsWindowFrameCount += 1
+ val nowNs = System.nanoTime()
+ val elapsedNs = nowNs - fpsWindowStartNs
+ if (elapsedNs < FPS_WINDOW_NS) {
+ return
+ }
+ val fps = (fpsWindowFrameCount * 1_000_000_000f) / elapsedNs.toFloat()
+ fpsUpdatedCallback?.invoke(fps)
+ fpsWindowStartNs = nowNs
+ fpsWindowFrameCount = 0
+ }
+
+ companion object {
+ private const val TAG = "AnnexBDecoder"
+ private const val MIME_AVC = "video/avc"
+ private const val INPUT_TIMEOUT_US = 10_000L
+ private const val CRITICAL_INPUT_TIMEOUT_US = 50_000L
+ private const val OUTPUT_TIMEOUT_US = 0L
+ private const val FPS_WINDOW_NS = 1_000_000_000L
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt
new file mode 100644
index 0000000..24088bb
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/DirectAdbClient.kt
@@ -0,0 +1,506 @@
+package io.github.miuzarte.scrcpyforandroid.nativecore
+
+import android.content.Context
+import android.util.Base64
+import android.util.Log
+import androidx.core.content.edit
+import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
+import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
+import java.io.BufferedInputStream
+import java.io.Closeable
+import java.io.EOFException
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.math.BigInteger
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.PrivateKey
+import java.security.Signature
+import java.security.interfaces.RSAPrivateCrtKey
+import java.security.spec.PKCS8EncodedKeySpec
+import java.security.spec.RSAPublicKeySpec
+import java.security.spec.X509EncodedKeySpec
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.concurrent.thread
+
+internal class DirectAdbTransport(private val context: Context) {
+
+ private val keys: Pair by lazy { loadOrCreate() }
+
+ val privateKey: PrivateKey get() = keys.first
+ val publicKeyX509: ByteArray get() = keys.second
+
+ @Volatile var keyName: String = AppDefaults.DefaultAdbKeyName
+
+ fun connect(host: String, port: Int): DirectAdbConnection {
+ Log.i(TAG, "connect(): opening direct adbd transport to $host:$port")
+ val conn = DirectAdbConnection(host, port, privateKey, publicKeyX509, keyName.ifBlank { AppDefaults.DefaultAdbKeyName })
+ conn.handshake()
+ Log.i(TAG, "connect(): handshake success for $host:$port")
+ return conn
+ }
+
+ private fun loadOrCreate(): Pair {
+ val prefs = context.getSharedPreferences(AppPreferenceKeys.NativeAdbKeyPrefsName, Context.MODE_PRIVATE)
+ val privB64 = prefs.getString(AppPreferenceKeys.NativeAdbPrivateKey, null)
+ if (privB64 != null) {
+ try {
+ val kf = KeyFactory.getInstance("RSA")
+ val priv = kf.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(privB64, Base64.DEFAULT)))
+ val pub = derivePublicX509(priv)
+ Log.i(TAG, "loadOrCreate(): loaded persisted RSA key pair, fp=${fingerprint(pub)}")
+ return Pair(priv, pub)
+ } catch (e: Exception) {
+ Log.w(TAG, "loadOrCreate(): failed to load persisted key, regenerating", e)
+ }
+ }
+ val kpg = KeyPairGenerator.getInstance("RSA")
+ kpg.initialize(2048)
+ val kp = kpg.generateKeyPair()
+ prefs.edit { putString(AppPreferenceKeys.NativeAdbPrivateKey, Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)) }
+ Log.i(TAG, "loadOrCreate(): generated new RSA key pair, fp=${fingerprint(kp.public.encoded)}")
+ return Pair(kp.private, kp.public.encoded)
+ }
+
+ private fun derivePublicX509(privateKey: PrivateKey): ByteArray {
+ val rsa = privateKey as? RSAPrivateCrtKey
+ ?: throw IllegalStateException("Expected RSAPrivateCrtKey but was ${privateKey.javaClass.name}")
+ val kf = KeyFactory.getInstance("RSA")
+ val public = kf.generatePublic(RSAPublicKeySpec(rsa.modulus, rsa.publicExponent))
+ return public.encoded
+ }
+
+ private fun fingerprint(publicX509: ByteArray): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(publicX509)
+ return digest.joinToString(":") { b -> "%02x".format(b) }
+ }
+
+ companion object {
+ private const val TAG = "DirectAdbTransport"
+ }
+}
+
+internal class DirectAdbConnection(
+ val host: String,
+ val port: Int,
+ private val privateKey: PrivateKey,
+ private val publicKeyX509: ByteArray,
+ private val keyName: String = AppDefaults.DefaultAdbKeyName,
+) : AutoCloseable {
+
+ private val sha1DigestInfoPrefix = byteArrayOf(
+ 0x30,
+ 0x21,
+ 0x30,
+ 0x09,
+ 0x06,
+ 0x05,
+ 0x2B,
+ 0x0E,
+ 0x03,
+ 0x02,
+ 0x1A,
+ 0x05,
+ 0x00,
+ 0x04,
+ 0x14,
+ )
+
+ private val socket = Socket()
+ private lateinit var rawIn: BufferedInputStream
+ private lateinit var rawOut: OutputStream
+ private val nextLocalId = AtomicInteger(1)
+ private val streams = ConcurrentHashMap()
+ @Volatile private var closed = false
+ private var readerThread: Thread? = null
+
+ companion object {
+ private const val TAG = "DirectAdbConnection"
+ private const val A_CNXN = 0x4e584e43
+ private const val A_AUTH = 0x48545541
+ private const val A_OPEN = 0x4e45504f
+ private const val A_OKAY = 0x59414b4f
+ private const val A_CLSE = 0x45534c43
+ private const val A_WRTE = 0x45545257
+ private const val AUTH_TOKEN = 1
+ private const val AUTH_SIGNATURE = 2
+ private const val AUTH_RSAPUBLICKEY = 3
+ private const val VERSION = 0x01000001
+ private const val MAX_PAYLOAD = 256 * 1024
+ }
+
+ fun handshake() {
+ Log.i(TAG, "handshake(): tcp connect -> $host:$port")
+ socket.connect(InetSocketAddress(host, port), 10_000)
+ socket.tcpNoDelay = true
+ socket.soTimeout = 60_000
+ rawIn = BufferedInputStream(socket.getInputStream(), 65_536)
+ rawOut = socket.getOutputStream()
+
+ sendMsg(A_CNXN, VERSION, MAX_PAYLOAD, "host::\u0000".toByteArray(Charsets.UTF_8))
+
+ val first = recvMsg()
+ when (first.command) {
+ A_CNXN -> Unit
+ A_AUTH -> {
+ if (first.arg0 != AUTH_TOKEN) {
+ throw IOException("ADB: expected AUTH_TOKEN, got type=${first.arg0}")
+ }
+ sendMsg(A_AUTH, AUTH_SIGNATURE, 0, signToken(first.data))
+ val afterSign = recvMsg()
+ when (afterSign.command) {
+ A_CNXN -> Unit
+ A_AUTH -> {
+ if (afterSign.arg0 != AUTH_TOKEN) {
+ throw IOException("ADB: expected AUTH_TOKEN after rejected signature, got type=${afterSign.arg0}")
+ }
+ sendMsg(A_AUTH, AUTH_RSAPUBLICKEY, 0, buildAdbPubKey())
+ val cnxn = recvMsg()
+ if (cnxn.command != A_CNXN) {
+ throw IOException("ADB: connection rejected. Please accept the authorisation dialog on the target device.")
+ }
+ }
+ else -> throw IOException("ADB: unexpected message 0x${afterSign.command.toString(16)} after AUTH_SIGNATURE")
+ }
+ }
+ else -> throw IOException("ADB: unexpected initial message 0x${first.command.toString(16)}")
+ }
+
+ socket.soTimeout = 0
+ readerThread = thread(isDaemon = true, name = "adb-reader-$host:$port") { readLoop() }
+ }
+
+ fun openStream(service: String): AdbSocketStream {
+ val localId = nextLocalId.getAndIncrement()
+ val stream = AdbSocketStream(localId) { cmd, a0, a1, d -> sendMsg(cmd, a0, a1, d) }
+ streams[localId] = stream
+ sendMsg(A_OPEN, localId, 0, (service + "\u0000").toByteArray(Charsets.UTF_8))
+ try {
+ stream.awaitOpen(15_000)
+ } catch (e: Exception) {
+ streams.remove(localId)
+ throw e
+ }
+ return stream
+ }
+
+ fun shell(command: String): String =
+ openStream("shell:$command").use { it.inputStream.readBytes().toString(Charsets.UTF_8) }
+
+ fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) {
+ openStream("sync:").use { stream ->
+ val out = stream.outputStream
+ val inp = stream.inputStream
+ val pathMode = "$remotePath,$unixMode".toByteArray(Charsets.UTF_8)
+
+ out.write("SEND".toByteArray(Charsets.US_ASCII))
+ out.writeIntLE(pathMode.size)
+ out.write(pathMode)
+
+ val chunkBuf = ByteArray(64 * 1024)
+ var offset = 0
+ while (offset < data.size) {
+ val len = minOf(chunkBuf.size, data.size - offset)
+ out.write("DATA".toByteArray(Charsets.US_ASCII))
+ out.writeIntLE(len)
+ out.write(data, offset, len)
+ offset += len
+ }
+
+ out.write("DONE".toByteArray(Charsets.US_ASCII))
+ out.writeIntLE((System.currentTimeMillis() / 1000).toInt())
+ out.flush()
+
+ val idBuf = ByteArray(4).also { inp.readExact(it) }
+ val msgLen = inp.readIntLE()
+ val id = String(idBuf, Charsets.US_ASCII)
+ if (id != "OKAY") {
+ val msg = if (msgLen > 0) ByteArray(msgLen).also { inp.readExact(it) }
+ .toString(Charsets.UTF_8) else id
+ throw IOException("ADB push failed: $msg")
+ } else if (msgLen > 0) {
+ inp.skip(msgLen.toLong())
+ }
+ }
+ }
+
+ fun isAlive(): Boolean = !closed && !socket.isClosed && socket.isConnected
+
+ override fun close() {
+ if (!closed) {
+ closed = true
+ streams.values.forEach { runCatching { it.forceClose() } }
+ streams.clear()
+ runCatching { socket.close() }
+ runCatching { readerThread?.interrupt() }
+ }
+ }
+
+ private fun readLoop() {
+ try {
+ while (!closed) {
+ val msg = recvMsg()
+ when (msg.command) {
+ A_OKAY -> streams[msg.arg1]?.onRemoteOkay(msg.arg0)
+ A_WRTE -> {
+ val s = streams[msg.arg1]
+ if (s != null) {
+ s.onData(msg.data)
+ sendMsg(A_OKAY, msg.arg1, msg.arg0)
+ } else {
+ sendMsg(A_CLSE, 0, msg.arg0)
+ }
+ }
+ A_CLSE -> streams.remove(msg.arg1)?.forceClose()
+ A_OPEN -> sendMsg(A_CLSE, 0, msg.arg0)
+ }
+ }
+ } catch (_: Exception) {
+ if (!closed) {
+ closed = true
+ streams.values.forEach { runCatching { it.forceClose() } }
+ }
+ }
+ }
+
+ @Synchronized
+ private fun sendMsg(command: Int, arg0: Int = 0, arg1: Int = 0, data: ByteArray = ByteArray(0)) {
+ val crc = data.fold(0L) { acc, b -> acc + (b.toLong() and 0xFF) }.toInt()
+ val header = ByteBuffer.allocate(24).order(ByteOrder.LITTLE_ENDIAN)
+ .putInt(command).putInt(arg0).putInt(arg1)
+ .putInt(data.size).putInt(crc).putInt(command xor -1)
+ .array()
+ rawOut.write(header)
+ if (data.isNotEmpty()) rawOut.write(data)
+ rawOut.flush()
+ }
+
+ private fun recvMsg(): AdbMsg {
+ val h = ByteArray(24)
+ rawIn.readExact(h)
+ val buf = ByteBuffer.wrap(h).order(ByteOrder.LITTLE_ENDIAN)
+ val command = buf.int
+ val arg0 = buf.int
+ val arg1 = buf.int
+ val dataLen = buf.int
+ buf.int
+ buf.int
+ val data = if (dataLen > 0) ByteArray(dataLen).also { rawIn.readExact(it) } else ByteArray(0)
+ return AdbMsg(command, arg0, arg1, data)
+ }
+
+ private data class AdbMsg(val command: Int, val arg0: Int, val arg1: Int, val data: ByteArray) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as AdbMsg
+
+ if (command != other.command) return false
+ if (arg0 != other.arg0) return false
+ if (arg1 != other.arg1) return false
+ if (!data.contentEquals(other.data)) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = command
+ result = 31 * result + arg0
+ result = 31 * result + arg1
+ result = 31 * result + data.contentHashCode()
+ return result
+ }
+ }
+
+ private fun signToken(token: ByteArray): ByteArray {
+ // adbd expects RSA signature over SHA-1 digest info where token is the digest payload.
+ val payload = ByteArray(sha1DigestInfoPrefix.size + token.size)
+ sha1DigestInfoPrefix.copyInto(payload, destinationOffset = 0)
+ token.copyInto(payload, destinationOffset = sha1DigestInfoPrefix.size)
+ return Signature.getInstance("NONEwithRSA").apply {
+ initSign(privateKey)
+ update(payload)
+ }.sign()
+ }
+
+ private fun buildAdbPubKey(): ByteArray {
+ val kf = KeyFactory.getInstance("RSA")
+ val pub = kf.generatePublic(X509EncodedKeySpec(publicKeyX509))
+ val spec = kf.getKeySpec(pub, RSAPublicKeySpec::class.java)
+ val adbKeyBytes = encodeAdbPublicKey(spec.modulus, spec.publicExponent.toInt())
+ return "${Base64.encodeToString(adbKeyBytes, Base64.NO_WRAP)} $keyName\u0000"
+ .toByteArray(Charsets.UTF_8)
+ }
+
+ private fun encodeAdbPublicKey(modulus: BigInteger, exponent: Int): ByteArray {
+ val words = 64
+ val bytes = 256
+ val two32 = BigInteger.ONE.shiftLeft(32)
+ val mask32 = two32.subtract(BigInteger.ONE)
+
+ fun toBigEndianPadded(n: BigInteger): ByteArray {
+ val raw = n.toByteArray()
+ val arr = ByteArray(bytes)
+ val src = if (raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw
+ src.copyInto(arr, destinationOffset = bytes - src.size)
+ return arr
+ }
+
+ val modBE = toBigEndianPadded(modulus)
+ // n0 is the least-significant 32 bits of modulus; for RSA modulus this must be odd.
+ val n0 = modulus.and(mask32)
+ val n0inv = n0.modInverse(two32).negate().mod(two32).toInt()
+ val r = BigInteger.ONE.shiftLeft(bytes * 8)
+ val rrBE = toBigEndianPadded(r.multiply(r).mod(modulus))
+
+ val buf = ByteBuffer.allocate(4 + 4 + bytes + bytes + 4).order(ByteOrder.LITTLE_ENDIAN)
+ buf.putInt(words)
+ buf.putInt(n0inv)
+ for (i in words - 1 downTo 0) {
+ val o = i * 4
+ buf.put(modBE[o + 3]); buf.put(modBE[o + 2]); buf.put(modBE[o + 1]); buf.put(modBE[o])
+ }
+ for (i in words - 1 downTo 0) {
+ val o = i * 4
+ buf.put(rrBE[o + 3]); buf.put(rrBE[o + 2]); buf.put(rrBE[o + 1]); buf.put(rrBE[o])
+ }
+ buf.putInt(exponent)
+ return buf.array()
+ }
+}
+
+internal class AdbSocketStream(
+ val localId: Int,
+ private val sender: (cmd: Int, arg0: Int, arg1: Int, data: ByteArray) -> Unit,
+) : Closeable {
+
+ companion object {
+ private const val A_WRTE = 0x45545257
+ private const val A_CLSE = 0x45534c43
+ }
+
+ @Volatile var remoteId: Int = 0
+ @Volatile var closed: Boolean = false
+
+ private val latch = CountDownLatch(1)
+ private val latchOk = AtomicBoolean(false)
+ private val queue = LinkedBlockingQueue()
+ private object EndOfStreamMarker
+
+ val inputStream: InputStream = InStream()
+ val outputStream: OutputStream = OutStream()
+
+ internal fun onRemoteOkay(remote: Int) {
+ if (remoteId == 0) {
+ remoteId = remote
+ latchOk.set(true)
+ latch.countDown()
+ }
+ }
+
+ internal fun onData(data: ByteArray) {
+ if (!closed) queue.offer(data)
+ }
+
+ internal fun forceClose() {
+ closed = true
+ queue.offer(EndOfStreamMarker)
+ latch.countDown()
+ }
+
+ fun awaitOpen(timeoutMs: Long) {
+ if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) {
+ throw IOException("ADB stream open timed out (localId=$localId)")
+ }
+ if (!latchOk.get()) {
+ throw IOException("ADB stream rejected by device (localId=$localId)")
+ }
+ }
+
+ override fun close() {
+ if (!closed) {
+ closed = true
+ val r = remoteId
+ if (r != 0) runCatching { sender(A_CLSE, localId, r, ByteArray(0)) }
+ queue.offer(EndOfStreamMarker)
+ }
+ }
+
+ private inner class InStream : InputStream() {
+ private var chunk: ByteArray? = null
+ private var off = 0
+
+ override fun read(): Int {
+ val b = ByteArray(1)
+ return if (read(b, 0, 1) == -1) -1 else (b[0].toInt() and 0xFF)
+ }
+
+ override fun read(b: ByteArray, off: Int, len: Int): Int {
+ while (true) {
+ val c = chunk
+ if (c != null && this.off < c.size) {
+ val n = minOf(len, c.size - this.off)
+ c.copyInto(b, off, this.off, this.off + n)
+ this.off += n
+ return n
+ }
+ chunk = null
+ this.off = 0
+ val next = queue.take()
+ if (next === EndOfStreamMarker) {
+ return -1
+ }
+ chunk = next as ByteArray
+ }
+ }
+
+ override fun available(): Int = chunk?.let { it.size - off } ?: 0
+ }
+
+ private inner class OutStream : OutputStream() {
+ override fun write(b: Int) = write(byteArrayOf(b.toByte()))
+ override fun write(b: ByteArray, off: Int, len: Int) {
+ if (closed) throw IOException("ADB stream closed")
+ if (len == 0) return
+ sender(A_WRTE, localId, remoteId, b.copyOfRange(off, off + len))
+ }
+ override fun flush() {}
+ }
+}
+
+private fun InputStream.readExact(buf: ByteArray) {
+ var off = 0
+ while (off < buf.size) {
+ val n = read(buf, off, buf.size - off)
+ if (n < 0) throw EOFException("readExact: expected ${buf.size} bytes, got $off")
+ off += n
+ }
+}
+
+private fun InputStream.readIntLE(): Int {
+ val b0 = read()
+ val b1 = read()
+ val b2 = read()
+ val b3 = read()
+ if (b3 < 0) throw EOFException("readIntLE: EOF")
+ return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
+}
+
+private fun OutputStream.writeIntLE(v: Int) {
+ write(v and 0xFF)
+ write(v shr 8 and 0xFF)
+ write(v shr 16 and 0xFF)
+ write(v shr 24 and 0xFF)
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
new file mode 100644
index 0000000..0589b49
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
@@ -0,0 +1,84 @@
+package io.github.miuzarte.scrcpyforandroid.nativecore
+
+import android.content.Context
+import android.util.Log
+import java.nio.file.Path
+
+class NativeAdbService(appContext: Context) {
+
+ private val transport = DirectAdbTransport(appContext)
+ @Volatile private var connection: DirectAdbConnection? = null
+ @Volatile private var connectedHost: String? = null
+ @Volatile private var connectedPort: Int? = null
+
+ var keyName: String
+ get() = transport.keyName
+ set(value) { transport.keyName = value }
+
+ @Synchronized
+ fun pair(host: String, port: Int, pairingCode: String): Boolean {
+ throw UnsupportedOperationException(
+ "Wireless pairing is not yet implemented. Please enable TCP ADB via USB first.",
+ )
+ }
+
+ @Synchronized
+ fun connect(host: String, port: Int): Boolean {
+ Log.i(TAG, "connect(): host=$host port=$port")
+ val existing = connection
+ if (existing != null && existing.isAlive() && connectedHost == host && connectedPort == port) {
+ return true
+ }
+ disconnect()
+ try {
+ val conn = transport.connect(host, port)
+ connection = conn
+ connectedHost = host
+ connectedPort = port
+ return true
+ } catch (e: Exception) {
+ Log.e(TAG, "connect(): failed host=$host port=$port", e)
+ val detail = e.message ?: "${e.javaClass.simpleName} (no message)"
+ throw IllegalStateException("ADB connect failed to $host:$port -> $detail", e)
+ }
+ }
+
+ @Synchronized
+ fun disconnect() {
+ runCatching { connection?.close() }
+ connection = null
+ connectedHost = null
+ connectedPort = null
+ }
+
+ @Synchronized
+ fun isConnected(): Boolean = connection?.isAlive() == true
+
+ @Synchronized
+ fun shell(command: String): String = requireConnection().shell(command)
+
+ @Synchronized
+ internal fun openShellStream(command: String): AdbSocketStream =
+ requireConnection().openStream("shell:$command")
+
+ @Synchronized
+ fun push(localPath: Path, remotePath: String) {
+ requireConnection().push(localPath.toFile().readBytes(), remotePath)
+ }
+
+ @Synchronized
+ internal fun openAbstractSocket(name: String): AdbSocketStream =
+ requireConnection().openStream("localabstract:$name")
+
+ @Synchronized
+ fun close() = disconnect()
+
+ private fun requireConnection(): DirectAdbConnection {
+ return connection?.takeIf { it.isAlive() }
+ ?: throw IllegalStateException("ADB not connected")
+ }
+
+ companion object {
+ private const val TAG = "NativeAdbService"
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt
new file mode 100644
index 0000000..d34bf09
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt
@@ -0,0 +1,201 @@
+package io.github.miuzarte.scrcpyforandroid.nativecore
+
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioManager
+import android.media.AudioTrack
+import android.os.Build
+import android.media.MediaCodec
+import android.media.MediaFormat
+import android.util.Log
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Decodes and plays scrcpy audio stream (OPUS / AAC / FLAC / RAW PCM).
+ *
+ * All [feedPacket] calls are expected from a single background thread.
+ * [release] may be called from any thread.
+ */
+class ScrcpyAudioPlayer(private val codecId: Int) {
+
+ private var mediaCodec: MediaCodec? = null
+ private var audioTrack: AudioTrack? = null
+ private val bufferInfo = MediaCodec.BufferInfo()
+
+ @Volatile private var prepared = false
+ @Volatile private var released = false
+ private var packetCount = 0L
+
+ fun feedPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
+ if (released) return
+
+ if (isConfig) {
+ Log.i(TAG, "feedPacket(): config packet size=${data.size} codec=0x${codecId.toUInt().toString(16)}")
+ when (codecId) {
+ AUDIO_CODEC_OPUS -> prepareOpus(data)
+ AUDIO_CODEC_AAC -> prepareAac(data)
+ AUDIO_CODEC_FLAC -> prepareFlac(data)
+ // RAW has no config packet
+ }
+ return
+ }
+
+ packetCount += 1
+ if (packetCount == 1L || packetCount % 120L == 0L) {
+ Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}")
+ }
+
+ if (codecId == AUDIO_CODEC_RAW) {
+ ensureRawAudioTrack()?.let { track ->
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ track.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
+ } else {
+ track.write(data, 0, data.size)
+ }
+ }
+ return
+ }
+
+ if (!prepared) return
+ val codec = mediaCodec ?: return
+
+ val inputIdx = codec.dequeueInputBuffer(CODEC_TIMEOUT_US)
+ if (inputIdx >= 0) {
+ val buf = codec.getInputBuffer(inputIdx) ?: return
+ buf.clear()
+ buf.put(data)
+ codec.queueInputBuffer(inputIdx, 0, data.size, ptsUs, 0)
+ }
+ drainOutput(codec)
+ }
+
+ // OpusHead bytes (already extracted by server's fixOpusConfigPacket)
+ private fun prepareOpus(opusHead: ByteArray) {
+ if (prepared || released) return
+ runCatching {
+ val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, SAMPLE_RATE, CHANNELS)
+ format.setByteBuffer("csd-0", ByteBuffer.wrap(opusHead))
+ // pre-skip field: 2 bytes LE at offset 10 of the OpusHead
+ if (opusHead.size >= 12) {
+ val preSkip = ((opusHead[11].toInt() and 0xFF) shl 8) or (opusHead[10].toInt() and 0xFF)
+ val codecDelayNs = preSkip.toLong() * 1_000_000_000L / SAMPLE_RATE
+ format.setByteBuffer("csd-1", longBuffer(codecDelayNs))
+ format.setByteBuffer("csd-2", longBuffer(OPUS_SEEK_PREROLL_NS))
+ }
+ startCodecAndTrack(format)
+ }.onFailure { Log.w(TAG, "prepareOpus failed", it) }
+ }
+
+ private fun prepareAac(aacConfig: ByteArray) {
+ if (prepared || released) return
+ runCatching {
+ val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, CHANNELS)
+ format.setByteBuffer("csd-0", ByteBuffer.wrap(aacConfig))
+ startCodecAndTrack(format)
+ }.onFailure { Log.w(TAG, "prepareAac failed", it) }
+ }
+
+ private fun prepareFlac(flacConfig: ByteArray) {
+ if (prepared || released) return
+ runCatching {
+ val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_FLAC, SAMPLE_RATE, CHANNELS)
+ if (flacConfig.isNotEmpty()) {
+ format.setByteBuffer("csd-0", ByteBuffer.wrap(flacConfig))
+ }
+ startCodecAndTrack(format)
+ }.onFailure { Log.w(TAG, "prepareFlac failed", it) }
+ }
+
+ private fun startCodecAndTrack(format: MediaFormat) {
+ val mime = format.getString(MediaFormat.KEY_MIME)!!
+ val codec = MediaCodec.createDecoderByType(mime)
+ codec.configure(format, null, null, 0)
+ val track = buildAudioTrack()
+ codec.start()
+ track.play()
+ mediaCodec = codec
+ audioTrack = track
+ prepared = true
+ Log.i(TAG, "audio player started: mime=$mime sampleRate=$SAMPLE_RATE ch=$CHANNELS")
+ }
+
+ private fun ensureRawAudioTrack(): AudioTrack? {
+ if (released) return null
+ if (audioTrack == null) {
+ val track = buildAudioTrack()
+ track.play()
+ audioTrack = track
+ prepared = true
+ }
+ return audioTrack
+ }
+
+ private fun buildAudioTrack(): AudioTrack {
+ val minBuf = AudioTrack.getMinBufferSize(
+ SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT,
+ ).coerceAtLeast(1)
+ return AudioTrack(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
+ .build(),
+ AudioFormat.Builder()
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setSampleRate(SAMPLE_RATE)
+ .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
+ .build(),
+ (minBuf * 4).coerceAtLeast(65536),
+ AudioTrack.MODE_STREAM,
+ AudioManager.AUDIO_SESSION_ID_GENERATE,
+ )
+ }
+
+ private fun drainOutput(codec: MediaCodec) {
+ val track = audioTrack ?: return
+ var idx = codec.dequeueOutputBuffer(bufferInfo, 0L)
+ while (idx >= 0) {
+ val outBuf = codec.getOutputBuffer(idx) ?: break
+ val size = bufferInfo.size
+ if (size > 0) {
+ val pcm = ByteArray(size)
+ outBuf.position(bufferInfo.offset)
+ outBuf.get(pcm)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ track.write(pcm, 0, size, AudioTrack.WRITE_NON_BLOCKING)
+ } else {
+ track.write(pcm, 0, size)
+ }
+ }
+ codec.releaseOutputBuffer(idx, false)
+ idx = codec.dequeueOutputBuffer(bufferInfo, 0L)
+ }
+ }
+
+ fun release() {
+ if (released) return
+ released = true
+ prepared = false
+ runCatching { mediaCodec?.stop() }
+ runCatching { mediaCodec?.release() }
+ runCatching { audioTrack?.stop() }
+ runCatching { audioTrack?.release() }
+ mediaCodec = null
+ audioTrack = null
+ }
+
+ private fun longBuffer(value: Long): ByteBuffer =
+ ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply { putLong(value); flip() }
+
+ companion object {
+ private const val TAG = "ScrcpyAudioPlayer"
+ const val AUDIO_CODEC_OPUS = 0x6f707573
+ const val AUDIO_CODEC_AAC = 0x00616163
+ const val AUDIO_CODEC_FLAC = 0x666c6163
+ const val AUDIO_CODEC_RAW = 0x00726177
+ private const val SAMPLE_RATE = 48000
+ private const val CHANNELS = 2
+ private const val CODEC_TIMEOUT_US = 10_000L
+ private const val OPUS_SEEK_PREROLL_NS = 80_000_000L // 80 ms
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt
new file mode 100644
index 0000000..0405161
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpySessionManager.kt
@@ -0,0 +1,1060 @@
+package io.github.miuzarte.scrcpyforandroid.nativecore
+
+import android.util.Log
+import android.view.KeyEvent
+import java.io.BufferedInputStream
+import java.io.BufferedReader
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.io.EOFException
+import java.io.InputStreamReader
+import java.nio.file.Path
+import java.security.SecureRandom
+import java.util.ArrayDeque
+import java.util.LinkedHashSet
+import kotlin.concurrent.thread
+import kotlin.math.roundToInt
+
+class ScrcpySessionManager(private val adbService: NativeAdbService) {
+ @Volatile
+ private var activeSession: ActiveSession? = null
+ @Volatile
+ private var videoConsumer: ((VideoPacket) -> Unit)? = null
+ @Volatile
+ private var videoReaderThread: Thread? = null
+ @Volatile
+ private var audioConsumer: ((AudioPacket) -> Unit)? = null
+ @Volatile
+ private var audioReaderThread: Thread? = null
+ @Volatile
+ private var lastServerCommand: String? = null
+ private val serverLogBuffer = ArrayDeque()
+
+ @Synchronized
+ fun start(serverJarPath: Path, options: ScrcpyStartOptions): SessionInfo {
+ stop()
+ synchronized(this) {
+ serverLogBuffer.clear()
+ }
+ val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
+ val scid = random.nextInt(Int.MAX_VALUE)
+ val socketName = socketNameFor(scid)
+
+ try {
+ adbService.push(serverJarPath, targetPath)
+ val cmd = buildServerCommand(targetPath, scid, options)
+ lastServerCommand = cmd
+ Log.i(TAG, "start(): socket=$socketName codec=${options.videoCodec} audio=${options.audio} audioCodec=${options.audioCodec}")
+ val serverStream = adbService.openShellStream(cmd)
+ val serverLogThread = startServerLogThread(serverStream, socketName)
+ Thread.sleep(SERVER_BOOT_DELAY_MS)
+
+ // The first socket always carries device meta (and dummy byte).
+ val firstStream = openAbstractSocketWithRetry(socketName, expectDummyByte = true)
+ val firstInput = DataInputStream(BufferedInputStream(firstStream.inputStream))
+
+ var videoStream: AdbSocketStream? = null
+ var videoInput: DataInputStream? = null
+ var audioStream: AdbSocketStream? = null
+ var audioInput: DataInputStream? = null
+ var controlStream: AdbSocketStream? = null
+
+ when {
+ options.video -> {
+ videoStream = firstStream
+ videoInput = firstInput
+ }
+ options.audio -> {
+ audioStream = firstStream
+ audioInput = firstInput
+ }
+ options.control -> {
+ controlStream = firstStream
+ }
+ else -> {
+ throw IllegalArgumentException("At least one of video/audio/control must be enabled")
+ }
+ }
+
+ if (options.video && videoStream == null) {
+ val vStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
+ videoStream = vStream
+ videoInput = DataInputStream(BufferedInputStream(vStream.inputStream))
+ }
+
+ if (options.audio && audioStream == null) {
+ val aStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
+ audioStream = aStream
+ audioInput = DataInputStream(BufferedInputStream(aStream.inputStream))
+ }
+
+ if (options.control && controlStream == null) {
+ controlStream = openAbstractSocketWithRetry(socketName, expectDummyByte = false)
+ }
+
+ val deviceName = readDeviceName(firstInput)
+ val audioCodecId = if (options.audio) audioCodecIdFromName(options.audioCodec) else 0
+ val codecId: Int
+ val width: Int
+ val height: Int
+ if (options.video) {
+ val vInput = checkNotNull(videoInput)
+ codecId = vInput.readInt()
+ width = vInput.readInt()
+ height = vInput.readInt()
+ } else {
+ codecId = 0
+ width = 0
+ height = 0
+ }
+
+ val sessionInfo = SessionInfo(
+ deviceName = deviceName,
+ codecId = codecId,
+ codecName = codecName(codecId),
+ width = width,
+ height = height,
+ audioCodecId = audioCodecId,
+ controlEnabled = controlStream != null,
+ )
+
+ activeSession = ActiveSession(
+ info = sessionInfo,
+ socketName = socketName,
+ serverStream = serverStream,
+ serverLogThread = serverLogThread,
+ videoStream = videoStream,
+ videoInput = videoInput,
+ audioStream = audioStream,
+ audioInput = audioInput,
+ controlStream = controlStream,
+ controlWriter = controlStream?.outputStream?.let { ScrcpyControlWriter(DataOutputStream(it)) },
+ )
+ return sessionInfo
+ } catch (t: Throwable) {
+ val tail = snapshotServerLogs(maxLines = 120)
+ val detail = if (tail.isBlank()) "" else " | server_log_tail=\n$tail"
+ throw IllegalStateException("scrcpy start failed: ${t.message}$detail", t)
+ }
+ }
+
+ @Synchronized
+ fun attachVideoConsumer(consumer: (VideoPacket) -> Unit) {
+ val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
+ val vInput = session.videoInput ?: return
+ val vStream = session.videoStream ?: return
+ videoConsumer = consumer
+ if (videoReaderThread?.isAlive == true) {
+ return
+ }
+
+ videoReaderThread = thread(start = true, name = "scrcpy-video-reader") {
+ while (activeSession === session && !vStream.closed) {
+ try {
+ val ptsAndFlags = vInput.readLong()
+ val packetSize = vInput.readInt()
+ if (packetSize <= 0) {
+ continue
+ }
+
+ val payload = ByteArray(packetSize)
+ vInput.readFully(payload)
+
+ val config = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
+ val keyFrame = (ptsAndFlags and PACKET_FLAG_KEY_FRAME) != 0L
+ val ptsUs = ptsAndFlags and PACKET_PTS_MASK
+ videoConsumer?.invoke(
+ VideoPacket(
+ data = payload,
+ ptsUs = ptsUs,
+ isConfig = config,
+ isKeyFrame = keyFrame,
+ ),
+ )
+ } catch (_: EOFException) {
+ break
+ } catch (e: InterruptedException) {
+ // Ignore transient interrupts while session remains active.
+ if (activeSession !== session || vStream.closed) {
+ break
+ }
+ Thread.interrupted()
+ } catch (e: Exception) {
+ Log.w(TAG, "video reader failed", e)
+ break
+ }
+ }
+ }
+ }
+
+ @Synchronized
+ fun clearVideoConsumer() {
+ videoConsumer = null
+ }
+
+ @Synchronized
+ fun attachAudioConsumer(consumer: (AudioPacket) -> Unit) {
+ val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
+ val aInput = session.audioInput ?: return // audio disabled or unavailable
+ val aStream = session.audioStream ?: return
+ audioConsumer = consumer
+ if (audioReaderThread?.isAlive == true) return
+
+ audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") {
+ val streamCodecId = try {
+ aInput.readInt()
+ } catch (e: Exception) {
+ Log.w(TAG, "audio codec header read failed", e)
+ return@thread
+ }
+ when (streamCodecId) {
+ AUDIO_DISABLED -> {
+ Log.w(TAG, "audio disabled by server")
+ return@thread
+ }
+ AUDIO_ERROR -> {
+ Log.e(TAG, "audio stream configuration error from server")
+ return@thread
+ }
+ else -> {
+ Log.i(TAG, "audio stream codec=0x${streamCodecId.toUInt().toString(16)}")
+ }
+ }
+ if (session.info.audioCodecId != 0 && streamCodecId != session.info.audioCodecId) {
+ Log.w(
+ TAG,
+ "audio codec mismatch: requested=0x${session.info.audioCodecId.toUInt().toString(16)} stream=0x${streamCodecId.toUInt().toString(16)}",
+ )
+ }
+
+ while (activeSession === session && !aStream.closed) {
+ try {
+ val ptsAndFlags = aInput.readLong()
+ val packetSize = aInput.readInt()
+ if (packetSize <= 0) continue
+
+ val payload = ByteArray(packetSize)
+ aInput.readFully(payload)
+
+ val isConfig = (ptsAndFlags and PACKET_FLAG_CONFIG) != 0L
+ val ptsUs = ptsAndFlags and PACKET_PTS_MASK
+ audioConsumer?.invoke(AudioPacket(data = payload, ptsUs = ptsUs, isConfig = isConfig))
+ } catch (_: EOFException) {
+ break
+ } catch (e: InterruptedException) {
+ if (activeSession !== session || aStream.closed) break
+ Thread.interrupted()
+ } catch (e: Exception) {
+ Log.w(TAG, "audio reader failed", e)
+ break
+ }
+ }
+ }
+ }
+
+ @Synchronized
+ fun clearAudioConsumer() {
+ audioConsumer = null
+ }
+
+ @Synchronized
+ fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) {
+ requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
+ }
+
+ @Synchronized
+ fun injectText(text: String) {
+ requireControlWriter().injectText(text)
+ }
+
+ @Synchronized
+ fun injectTouch(
+ action: Int,
+ pointerId: Long,
+ x: Int,
+ y: Int,
+ screenWidth: Int,
+ screenHeight: Int,
+ pressure: Float,
+ actionButton: Int,
+ buttons: Int,
+ ) {
+ requireControlWriter().injectTouch(action, pointerId, x, y, screenWidth, screenHeight, pressure, actionButton, buttons)
+ }
+
+ @Synchronized
+ fun injectScroll(x: Int, y: Int, screenWidth: Int, screenHeight: Int, hScroll: Float, vScroll: Float, buttons: Int) {
+ requireControlWriter().injectScroll(x, y, screenWidth, screenHeight, hScroll, vScroll, buttons)
+ }
+
+ @Synchronized
+ fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) {
+ requireControlWriter().pressBackOrScreenOn(action)
+ }
+
+ @Synchronized
+ fun setDisplayPower(on: Boolean) {
+ requireControlWriter().setDisplayPower(on)
+ }
+
+ @Synchronized
+ fun stop() {
+ val session = activeSession ?: return
+ activeSession = null
+ videoConsumer = null
+ audioConsumer = null
+
+ if (Thread.currentThread() !== videoReaderThread) {
+ runCatching { videoReaderThread?.interrupt() }
+ runCatching { videoReaderThread?.join(300) }
+ }
+ videoReaderThread = null
+
+ if (Thread.currentThread() !== audioReaderThread) {
+ runCatching { audioReaderThread?.interrupt() }
+ runCatching { audioReaderThread?.join(300) }
+ }
+ audioReaderThread = null
+
+ runCatching { session.controlStream?.close() }
+ runCatching { session.audioStream?.close() }
+ runCatching { session.videoStream?.close() }
+ runCatching { session.serverStream.close() }
+ if (Thread.currentThread() !== session.serverLogThread) {
+ runCatching { session.serverLogThread.interrupt() }
+ runCatching { session.serverLogThread.join(300) }
+ }
+ }
+
+ fun isStarted(): Boolean = activeSession != null
+
+ fun getLastServerCommand(): String? = lastServerCommand
+
+ @Synchronized
+ fun listEncoders(serverJarPath: Path, options: ScrcpyStartOptions): EncoderLists {
+ val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
+ adbService.push(serverJarPath, targetPath)
+ val cmd = buildServerCommand(
+ targetPath = targetPath,
+ scid = 0x1234abcd,
+ options = options.copy(
+ video = false,
+ audio = false,
+ control = false,
+ listEncoders = true,
+ cleanup = false,
+ ),
+ )
+ Log.i(TAG, "listEncoders(): cmd=$cmd")
+ // scrcpy encoder list is printed in logs, so merge stderr into stdout.
+ val output = adbService.shell("$cmd 2>&1")
+ val parsed = parseEncoderLists(output)
+ val preview = output.lineSequence().take(40).joinToString("\n")
+ Log.i(
+ TAG,
+ "listEncoders(): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview",
+ )
+ return parsed.copy(rawOutput = output)
+ }
+
+ @Synchronized
+ fun listCameraSizes(serverJarPath: Path, options: ScrcpyStartOptions): CameraSizeLists {
+ val targetPath = options.serverRemotePath.ifBlank { DEFAULT_SERVER_REMOTE_PATH }
+ adbService.push(serverJarPath, targetPath)
+ val cmd = buildServerCommand(
+ targetPath = targetPath,
+ scid = 0x2234abcd,
+ options = options.copy(
+ video = false,
+ audio = false,
+ control = false,
+ listCameraSizes = true,
+ cleanup = false,
+ ),
+ )
+ Log.i(TAG, "listCameraSizes(): cmd=$cmd")
+ val output = adbService.shell("$cmd 2>&1")
+ val parsed = parseCameraSizeLists(output)
+ val preview = output.lineSequence().take(40).joinToString("\n")
+ Log.i(
+ TAG,
+ "listCameraSizes(): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview",
+ )
+ return parsed.copy(rawOutput = output)
+ }
+
+ private fun requireControlWriter(): ScrcpyControlWriter {
+ return activeSession?.controlWriter ?: throw IllegalStateException("scrcpy control channel not available")
+ }
+
+ private fun buildServerCommand(targetPath: String, scid: Int, options: ScrcpyStartOptions): String {
+ val serverArgs = buildServerArgs(scid, options)
+ return "CLASSPATH=$targetPath app_process / com.genymobile.scrcpy.Server ${options.serverVersion} $serverArgs"
+ }
+
+ private fun buildServerArgs(scid: Int, options: ScrcpyStartOptions): String {
+ val videoSource = options.videoSource.trim().ifBlank { "display" }
+ val isCameraSource = videoSource.equals("camera", ignoreCase = true)
+ val cameraId = options.cameraId.trim()
+ val hasCameraId = cameraId.isNotBlank()
+ val hasExplicitCameraSize = options.cameraSize.trim().isNotBlank()
+ val hasValidCameraFps = options.cameraFps > 0
+
+ val args = listOf(
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "scid",
+ value = String.format("%08x", scid),
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "log_level",
+ value = "debug",
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "tunnel_forward",
+ value = true,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "cleanup",
+ value = options.cleanup,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "send_device_meta",
+ value = true,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "send_frame_meta",
+ value = true,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "send_dummy_byte",
+ value = true,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "send_codec_meta",
+ value = true,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "video",
+ value = options.video,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "audio",
+ value = options.audio,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "control",
+ value = options.control,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "video_source",
+ value = videoSource,
+ ),
+ ServerArg(
+ type = ServerArgType.NUMBER,
+ key = "max_size",
+ value = options.maxSize,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "video_codec",
+ value = options.videoCodec,
+ ),
+ ServerArg(
+ type = ServerArgType.NUMBER,
+ key = "video_bit_rate",
+ value = options.videoBitRate, zeroValueBehavior = ZeroValueBehavior.KEEP,
+ ),
+ ServerArg(
+ type = ServerArgType.NUMBER,
+ key = "max_fps",
+ value = options.maxFps,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "camera_id",
+ value = cameraId,
+ includeWhen = isCameraSource,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "camera_facing",
+ value = options.cameraFacing.trim(),
+ includeWhen = isCameraSource && !hasCameraId,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "camera_size",
+ value = options.cameraSize.trim(),
+ includeWhen = isCameraSource,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "camera_ar",
+ value = options.cameraAr.trim(),
+ includeWhen = isCameraSource && !hasExplicitCameraSize,
+ ),
+ ServerArg(
+ type = ServerArgType.NUMBER,
+ key = "camera_fps",
+ value = options.cameraFps,
+ includeWhen = isCameraSource,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "camera_high_speed",
+ value = options.cameraHighSpeed,
+ includeWhen = isCameraSource && options.cameraHighSpeed && hasValidCameraFps,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "audio_codec",
+ value = options.audioCodec,
+ includeWhen = options.audio,
+ ),
+ ServerArg(
+ type = ServerArgType.NUMBER,
+ key = "audio_bit_rate",
+ value = options.audioBitRate,
+ includeWhen = options.audio,
+ zeroValueBehavior = ZeroValueBehavior.KEEP,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "audio_source",
+ value = options.audioSource.trim(),
+ includeWhen = options.audio,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "audio_dup",
+ value = options.audioDup, includeWhen = options.audioDup,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "video_encoder",
+ value = options.videoEncoder.trim(),
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "video_codec_options",
+ value = options.videoCodecOptions.trim(),
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "audio_encoder",
+ value = options.audioEncoder.trim(),
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "audio_codec_options",
+ value = options.audioCodecOptions.trim(),
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "new_display",
+ value = options.newDisplay.trim(),
+ ),
+ ServerArg(
+ type = ServerArgType.NUMBER,
+ key = "display_id",
+ value = options.displayId,
+ ),
+ ServerArg(
+ type = ServerArgType.STRING,
+ key = "crop",
+ value = options.crop.trim(),
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "list_encoders",
+ value = options.listEncoders, includeWhen = options.listEncoders,
+ ),
+ ServerArg(
+ type = ServerArgType.BOOLEAN,
+ key = "list_camera_sizes",
+ value = options.listCameraSizes, includeWhen = options.listCameraSizes,
+ ),
+ // Reserved for future args that require repeated/list values.
+ // ServerArg(type = ServerArgType.LIST, key = "_unused_list", value = emptyList(), includeWhen = false),
+ )
+
+ val rendered = args.mapNotNull { it.render() }
+ val header = rendered.firstOrNull()
+ val kv = rendered.drop(1)
+ return if (header == null) {
+ kv.joinToString(" ")
+ } else {
+ "$header ${kv.joinToString(" ")}".trim()
+ }
+ }
+
+ private enum class ServerArgType {
+ NUMBER,
+ STRING,
+ BOOLEAN,
+ LIST,
+ }
+
+ private enum class ZeroValueBehavior {
+ KEEP,
+ OMIT,
+ }
+
+ private data class ServerArg(
+ val type: ServerArgType,
+ val key: String,
+ val value: T?,
+ val includeWhen: Boolean = true,
+ val zeroValueBehavior: ZeroValueBehavior = ZeroValueBehavior.OMIT,
+ ) {
+ fun render(): String? {
+ if (!includeWhen) return null
+ return when (type) {
+ ServerArgType.STRING -> renderString(value as? String)
+ ServerArgType.BOOLEAN -> renderBoolean(value as? Boolean)
+ ServerArgType.NUMBER -> renderNumber(value as? Number)
+ ServerArgType.LIST -> renderList(value as? Iterable<*>)
+ }
+ }
+
+ private fun renderString(raw: String?): String? {
+ val normalized = raw?.trim().orEmpty()
+ if (normalized.isBlank()) return null
+ return "$key=$normalized"
+ }
+
+ private fun renderBoolean(raw: Boolean?): String? {
+ val normalized = raw ?: return null
+ return "$key=$normalized"
+ }
+
+ private fun renderNumber(raw: Number?): String? {
+ val normalized = raw ?: return null
+ val asDouble = normalized.toDouble()
+ if (asDouble == 0.0 && zeroValueBehavior == ZeroValueBehavior.OMIT) return null
+ val valueString = when (normalized) {
+ is Float -> normalized.toString()
+ is Double -> normalized.toString()
+ else -> normalized.toLong().toString()
+ }
+ return "$key=$valueString"
+ }
+
+ private fun renderList(raw: Iterable<*>?): String? {
+ val items = raw
+ ?.mapNotNull { it?.toString()?.trim() }
+ ?.filter { it.isNotBlank() }
+ .orEmpty()
+ if (items.isEmpty()) return null
+ return "$key=${items.joinToString(",")}"
+ }
+ }
+
+ private fun parseEncoderLists(output: String): EncoderLists {
+ val video = LinkedHashSet()
+ val audio = LinkedHashSet()
+ val videoTypes = linkedMapOf()
+ val audioTypes = linkedMapOf()
+ VIDEO_ENCODER_REGEX.findAll(output).forEach { match ->
+ video.add(match.groupValues[1])
+ }
+ AUDIO_ENCODER_REGEX.findAll(output).forEach { match ->
+ audio.add(match.groupValues[1])
+ }
+ // Fallback for log formats that include codec+encoder in one line.
+ VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
+ video.add(match.groupValues[1])
+ }
+ AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
+ audio.add(match.groupValues[1])
+ }
+ VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
+ val name = match.groupValues[1]
+ val type = match.groupValues[2]
+ if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) {
+ videoTypes[name] = type
+ }
+ }
+ AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
+ val name = match.groupValues[1]
+ val type = match.groupValues[2]
+ if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) {
+ audioTypes[name] = type
+ }
+ }
+ return EncoderLists(
+ videoEncoders = video.toList(),
+ audioEncoders = audio.toList(),
+ videoEncoderTypes = videoTypes,
+ audioEncoderTypes = audioTypes,
+ )
+ }
+
+ private fun parseCameraSizeLists(output: String): CameraSizeLists {
+ val sizes = LinkedHashSet()
+ CAMERA_SIZE_REGEX.findAll(output).forEach { match ->
+ sizes.add(match.groupValues[1])
+ }
+ CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match ->
+ sizes.add(match.groupValues[1])
+ }
+ return CameraSizeLists(sizes = sizes.toList())
+ }
+
+ private fun startServerLogThread(serverStream: AdbSocketStream, socketName: String): Thread {
+ return thread(start = true, name = "scrcpy-server-log") {
+ try {
+ BufferedReader(InputStreamReader(serverStream.inputStream, Charsets.UTF_8)).use { reader ->
+ while (true) {
+ val line = reader.readLine() ?: break
+ appendServerLog(line)
+ Log.i(TAG, "[server:$socketName] $line")
+ }
+ }
+ } catch (e: Exception) {
+ if (activeSession != null) {
+ Log.w(TAG, "server log thread failed", e)
+ }
+ }
+ }
+ }
+
+ @Synchronized
+ private fun appendServerLog(line: String) {
+ if (serverLogBuffer.size >= SERVER_LOG_BUFFER_MAX_LINES) {
+ serverLogBuffer.removeFirst()
+ }
+ serverLogBuffer.addLast(line)
+ }
+
+ @Synchronized
+ private fun snapshotServerLogs(maxLines: Int): String {
+ if (serverLogBuffer.isEmpty()) {
+ return ""
+ }
+ val take = maxLines.coerceIn(1, SERVER_LOG_BUFFER_MAX_LINES)
+ return serverLogBuffer.toList().takeLast(take).joinToString("\n")
+ }
+
+ private fun openAbstractSocketWithRetry(socketName: String, expectDummyByte: Boolean): AdbSocketStream {
+ var lastEx: Exception? = null
+ repeat(CONNECT_RETRY_COUNT) { attempt ->
+ try {
+ val stream = adbService.openAbstractSocket(socketName)
+ if (expectDummyByte) {
+ val value = stream.inputStream.read()
+ if (value < 0) {
+ stream.close()
+ throw EOFException("scrcpy dummy byte missing")
+ }
+ }
+ return stream
+ } catch (e: Exception) {
+ lastEx = e
+ if (attempt < CONNECT_RETRY_COUNT - 1) Thread.sleep(CONNECT_RETRY_DELAY_MS)
+ }
+ }
+ throw IllegalStateException("Unable to open scrcpy socket '$socketName'", lastEx)
+ }
+
+ private fun readDeviceName(input: DataInputStream): String {
+ val buffer = ByteArray(DEVICE_NAME_FIELD_LENGTH)
+ input.readFully(buffer)
+ val firstZero = buffer.indexOf(0)
+ val length = if (firstZero >= 0) firstZero else buffer.size
+ return buffer.copyOf(length).toString(Charsets.UTF_8)
+ }
+
+ private fun codecName(codecId: Int): String {
+ return when (codecId) {
+ VIDEO_CODEC_H264 -> "h264"
+ VIDEO_CODEC_H265 -> "h265"
+ VIDEO_CODEC_AV1 -> "av1"
+ else -> "unknown"
+ }
+ }
+
+ private fun audioCodecIdFromName(name: String): Int {
+ return when (name.lowercase()) {
+ "opus" -> AUDIO_CODEC_OPUS
+ "aac" -> AUDIO_CODEC_AAC
+ "raw" -> AUDIO_CODEC_RAW
+ "flac" -> AUDIO_CODEC_FLAC
+ else -> 0
+ }
+ }
+
+ data class SessionInfo(
+ val deviceName: String,
+ val codecId: Int,
+ val codecName: String,
+ val width: Int,
+ val height: Int,
+ val audioCodecId: Int = 0,
+ val controlEnabled: Boolean,
+ )
+
+ data class VideoPacket(
+ 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 VideoPacket
+
+ 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
+ }
+ }
+
+ data class AudioPacket(
+ val data: ByteArray,
+ val ptsUs: Long,
+ val isConfig: Boolean,
+ ) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as AudioPacket
+
+ if (ptsUs != other.ptsUs) return false
+ if (isConfig != other.isConfig) 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 + data.contentHashCode()
+ return result
+ }
+ }
+
+ data class ScrcpyStartOptions(
+ val serverVersion: String = "3.3.4",
+ val serverRemotePath: String = DEFAULT_SERVER_REMOTE_PATH,
+ val video: Boolean = true,
+ val audio: Boolean = true,
+ val control: Boolean = true,
+ val cleanup: Boolean = true,
+ val maxSize: Int = 0,
+ val maxFps: Float = 0f,
+ val videoBitRate: Int = 8_000_000,
+ val videoCodec: String = "h264",
+ val audioBitRate: Int = 128_000,
+ val audioCodec: String = "opus",
+ val videoEncoder: String = "",
+ val videoCodecOptions: String = "",
+ val audioEncoder: String = "",
+ val audioCodecOptions: String = "",
+ val audioDup: Boolean = false,
+ val audioSource: String = "",
+ val videoSource: String = "display",
+ val cameraId: String = "",
+ val cameraFacing: String = "",
+ val cameraSize: String = "",
+ val cameraAr: String = "",
+ val cameraFps: Int = 0,
+ val cameraHighSpeed: Boolean = false,
+ val newDisplay: String = "",
+ val displayId: Int? = null,
+ val crop: String = "",
+ val listEncoders: Boolean = false,
+ val listCameraSizes: Boolean = false,
+ )
+
+ data class EncoderLists(
+ val videoEncoders: List,
+ val audioEncoders: List,
+ val videoEncoderTypes: Map = emptyMap(),
+ val audioEncoderTypes: Map = emptyMap(),
+ val rawOutput: String = "",
+ )
+
+ data class CameraSizeLists(
+ val sizes: List,
+ val rawOutput: String = "",
+ )
+
+ private data class ActiveSession(
+ val info: SessionInfo,
+ val socketName: String,
+ val serverStream: AdbSocketStream,
+ val serverLogThread: Thread,
+ val videoStream: AdbSocketStream?,
+ val videoInput: DataInputStream?,
+ val audioStream: AdbSocketStream?,
+ val audioInput: DataInputStream?,
+ val controlStream: AdbSocketStream?,
+ val controlWriter: ScrcpyControlWriter?,
+ )
+
+ private class ScrcpyControlWriter(private val output: DataOutputStream) {
+ @Synchronized
+ fun injectKeycode(action: Int, keycode: Int, repeat: Int, metaState: Int) {
+ output.writeByte(TYPE_INJECT_KEYCODE)
+ output.writeByte(action)
+ output.writeInt(keycode)
+ output.writeInt(repeat)
+ output.writeInt(metaState)
+ output.flush()
+ }
+
+ @Synchronized
+ fun injectText(text: String) {
+ val bytes = text.toByteArray(Charsets.UTF_8)
+ output.writeByte(TYPE_INJECT_TEXT)
+ output.writeInt(bytes.size)
+ output.write(bytes)
+ output.flush()
+ }
+
+ @Synchronized
+ fun injectTouch(
+ action: Int,
+ pointerId: Long,
+ x: Int,
+ y: Int,
+ screenWidth: Int,
+ screenHeight: Int,
+ pressure: Float,
+ actionButton: Int,
+ buttons: Int,
+ ) {
+ output.writeByte(TYPE_INJECT_TOUCH_EVENT)
+ output.writeByte(action)
+ output.writeLong(pointerId)
+ writePosition(x, y, screenWidth, screenHeight)
+ output.writeShort(encodeUnsignedFixedPoint16(pressure))
+ output.writeInt(actionButton)
+ output.writeInt(buttons)
+ output.flush()
+ }
+
+ @Synchronized
+ fun injectScroll(x: Int, y: Int, screenWidth: Int, screenHeight: Int, hScroll: Float, vScroll: Float, buttons: Int) {
+ output.writeByte(TYPE_INJECT_SCROLL_EVENT)
+ writePosition(x, y, screenWidth, screenHeight)
+ output.writeShort(encodeSignedFixedPoint16(hScroll / 16f))
+ output.writeShort(encodeSignedFixedPoint16(vScroll / 16f))
+ output.writeInt(buttons)
+ output.flush()
+ }
+
+ @Synchronized
+ fun pressBackOrScreenOn(action: Int) {
+ output.writeByte(TYPE_BACK_OR_SCREEN_ON)
+ output.writeByte(action)
+ output.flush()
+ }
+
+ @Synchronized
+ fun setDisplayPower(on: Boolean) {
+ output.writeByte(TYPE_SET_DISPLAY_POWER)
+ output.writeBoolean(on)
+ output.flush()
+ }
+
+ private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
+ output.writeInt(x)
+ output.writeInt(y)
+ output.writeShort(screenWidth)
+ output.writeShort(screenHeight)
+ }
+
+ private fun encodeUnsignedFixedPoint16(value: Float): Int {
+ val clamped = value.coerceIn(0f, 1f)
+ return if (clamped >= 1f) {
+ 0xffff
+ } else {
+ (clamped * 65536f).roundToInt().coerceIn(0, 0xfffe)
+ }
+ }
+
+ private fun encodeSignedFixedPoint16(value: Float): Int {
+ val clamped = value.coerceIn(-1f, 1f)
+ if (clamped >= 1f) {
+ return 0x7fff
+ }
+ if (clamped <= -1f) {
+ return -0x8000
+ }
+ return (clamped * 32768f).roundToInt().coerceIn(-0x8000, 0x7ffe)
+ }
+ }
+
+ companion object {
+ const val DEFAULT_SERVER_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
+ private const val TAG = "ScrcpySessionManager"
+ private const val SERVER_BOOT_DELAY_MS = 200L
+ private const val SERVER_LOG_BUFFER_MAX_LINES = 400
+
+ private const val CONNECT_RETRY_COUNT = 100
+ private const val CONNECT_RETRY_DELAY_MS = 100L
+ private const val DEVICE_NAME_FIELD_LENGTH = 64
+ private const val PACKET_FLAG_CONFIG = 1L shl 63
+ private const val PACKET_FLAG_KEY_FRAME = 1L shl 62
+ private const val PACKET_PTS_MASK = (1L shl 62) - 1
+ private const val VIDEO_CODEC_H264 = 0x68323634
+ private const val VIDEO_CODEC_H265 = 0x68323635
+ private const val VIDEO_CODEC_AV1 = 0x00617631
+ private const val AUDIO_CODEC_OPUS = 0x6f707573
+ private const val AUDIO_CODEC_AAC = 0x00616163
+ private const val AUDIO_CODEC_FLAC = 0x666c6163
+ private const val AUDIO_CODEC_RAW = 0x00726177
+ // Audio stream disable codes from server (writeDisableStream)
+ private const val AUDIO_DISABLED = 0
+ private const val AUDIO_ERROR = 1
+ private const val TYPE_INJECT_KEYCODE = 0
+ private const val TYPE_INJECT_TEXT = 1
+ private const val TYPE_INJECT_TOUCH_EVENT = 2
+ private const val TYPE_INJECT_SCROLL_EVENT = 3
+ private const val TYPE_BACK_OR_SCREEN_ON = 4
+ private const val TYPE_SET_DISPLAY_POWER = 10
+ private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)")
+ private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)")
+ private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['\"]?([^'\"\s]+)""")
+ private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['\"]?([^'\"\s]+)""")
+ private val VIDEO_ENCODER_TYPE_REGEX = Regex("""--video-codec=[^\s]+\s+--video-encoder=([^\s]+).*?\((hw|sw)\)""")
+ private val AUDIO_ENCODER_TYPE_REGEX = Regex("""--audio-codec=[^\s]+\s+--audio-encoder=([^\s]+).*?\((hw|sw)\)""")
+ private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)")
+ private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b")
+
+ private val random = SecureRandom()
+
+ private fun socketNameFor(scid: Int): String {
+ return "scrcpy_%08x".format(scid)
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt
new file mode 100644
index 0000000..2484790
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt
@@ -0,0 +1,676 @@
+package io.github.miuzarte.scrcpyforandroid.pages
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
+import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
+import kotlin.math.roundToInt
+import kotlinx.coroutines.launch
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.SnackbarHostState
+import top.yukonga.miuix.kmp.basic.ScrollBehavior
+import top.yukonga.miuix.kmp.basic.SpinnerEntry
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextField
+import top.yukonga.miuix.kmp.extra.SuperArrow
+import top.yukonga.miuix.kmp.extra.SuperDropdown
+import top.yukonga.miuix.kmp.extra.SuperSpinner
+import top.yukonga.miuix.kmp.extra.SuperSwitch
+
+private val AUDIO_SOURCE_OPTIONS = listOf(
+ "output" to "output",
+ "playback" to "playback",
+ "mic" to "mic",
+ "mic-unprocessed" to "mic-unprocessed",
+ "mic-camcorder" to "mic-camcorder",
+ "mic-voice-recognition" to "mic-voice-recognition",
+ "mic-voice-communication" to "mic-voice-communication",
+ "voice-call" to "voice-call",
+ "voice-call-uplink" to "voice-call-uplink",
+ "voice-call-downlink" to "voice-call-downlink",
+ "voice-performance" to "voice-performance",
+ "custom" to "自定义",
+)
+
+private val VIDEO_SOURCE_OPTIONS = listOf(
+ "display" to "display",
+ "camera" to "camera",
+)
+
+private val CAMERA_FACING_OPTIONS = listOf(
+ "" to "默认",
+ "front" to "front",
+ "back" to "back",
+ "external" to "external",
+)
+
+private val CAMERA_FPS_PRESETS = listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)
+
+@Composable
+internal fun AdvancedConfigPage(
+ contentPadding: PaddingValues,
+ scrollBehavior: ScrollBehavior,
+ snackbarHostState: SnackbarHostState,
+ sessionStarted: Boolean,
+ audioEnabled: Boolean,
+ noControl: Boolean,
+ onNoControlChange: (Boolean) -> Unit,
+ audioDup: Boolean,
+ onAudioDupChange: (Boolean) -> Unit,
+ audioSourcePreset: String,
+ onAudioSourcePresetChange: (String) -> Unit,
+ audioSourceCustom: String,
+ onAudioSourceCustomChange: (String) -> Unit,
+ videoSourcePreset: String,
+ onVideoSourcePresetChange: (String) -> Unit,
+ cameraIdInput: String,
+ onCameraIdInputChange: (String) -> Unit,
+ cameraFacingPreset: String,
+ onCameraFacingPresetChange: (String) -> Unit,
+ cameraSizePreset: String,
+ onCameraSizePresetChange: (String) -> Unit,
+ cameraSizeCustom: String,
+ onCameraSizeCustomChange: (String) -> Unit,
+ cameraSizeDropdownItems: List,
+ cameraSizeIndex: Int,
+ cameraArInput: String,
+ onCameraArInputChange: (String) -> Unit,
+ cameraFpsInput: String,
+ onCameraFpsInputChange: (String) -> Unit,
+ cameraHighSpeed: Boolean,
+ onCameraHighSpeedChange: (Boolean) -> Unit,
+ noAudioPlayback: Boolean,
+ onNoAudioPlaybackChange: (Boolean) -> Unit,
+ noVideo: Boolean,
+ onNoVideoChange: (Boolean) -> Unit,
+ requireAudio: Boolean,
+ onRequireAudioChange: (Boolean) -> Unit,
+ turnScreenOff: Boolean,
+ onTurnScreenOffChange: (Boolean) -> Unit,
+ maxSizeInput: String,
+ onMaxSizeInputChange: (String) -> Unit,
+ maxFpsInput: String,
+ onMaxFpsInputChange: (String) -> Unit,
+ videoEncoderDropdownItems: List,
+ videoEncoderTypeMap: Map,
+ videoEncoderIndex: Int,
+ onVideoEncoderChange: (String) -> Unit,
+ videoCodecOptions: String,
+ onVideoCodecOptionsChange: (String) -> Unit,
+ audioEncoderDropdownItems: List,
+ audioEncoderTypeMap: Map,
+ audioEncoderIndex: Int,
+ onAudioEncoderChange: (String) -> Unit,
+ audioCodecOptions: String,
+ onAudioCodecOptionsChange: (String) -> Unit,
+ onRefreshEncoders: () -> Unit,
+ onRefreshCameraSizes: () -> Unit,
+ newDisplayWidth: String,
+ onNewDisplayWidthChange: (String) -> Unit,
+ newDisplayHeight: String,
+ onNewDisplayHeightChange: (String) -> Unit,
+ newDisplayDpi: String,
+ onNewDisplayDpiChange: (String) -> Unit,
+ displayIdInput: String,
+ onDisplayIdInputChange: (String) -> Unit,
+ cropWidth: String,
+ onCropWidthChange: (String) -> Unit,
+ cropHeight: String,
+ onCropHeightChange: (String) -> Unit,
+ cropX: String,
+ onCropXChange: (String) -> Unit,
+ cropY: String,
+ onCropYChange: (String) -> Unit,
+) {
+ val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ val maxSizePresetIndex = presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize)
+ val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS)
+ val audioSourceItems = AUDIO_SOURCE_OPTIONS.map { it.second }
+ val audioSourceIndex = AUDIO_SOURCE_OPTIONS.indexOfFirst { it.first == audioSourcePreset }.let { if (it >= 0) it else 0 }
+ val videoSourceItems = VIDEO_SOURCE_OPTIONS.map { it.second }
+ val videoSourceIndex = VIDEO_SOURCE_OPTIONS.indexOfFirst { it.first == videoSourcePreset }.let { if (it >= 0) it else 0 }
+ val cameraFacingItems = CAMERA_FACING_OPTIONS.map { it.second }
+ val cameraFacingIndex = CAMERA_FACING_OPTIONS.indexOfFirst { it.first == cameraFacingPreset }.let { if (it >= 0) it else 0 }
+ val cameraFpsPresetIndex = presetIndexFromInputForAdvancedPage(cameraFpsInput, CAMERA_FPS_PRESETS)
+ val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName ->
+ if (encoderName == "默认") {
+ SpinnerEntry(title = encoderName)
+ } else {
+ val type = resolveEncoderTypeLabel(videoEncoderTypeMap[encoderName])
+ SpinnerEntry(
+ title = encoderName,
+ summary = type.ifBlank { null },
+ )
+ }
+ }
+ val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName ->
+ if (encoderName == "默认") {
+ SpinnerEntry(title = encoderName)
+ } else {
+ val type = resolveEncoderTypeLabel(audioEncoderTypeMap[encoderName])
+ SpinnerEntry(
+ title = encoderName,
+ summary = type.ifBlank { null },
+ )
+ }
+ }
+
+ // 高级参数
+ AppPageLazyColumn(
+ contentPadding = contentPadding,
+ scrollBehavior = scrollBehavior,
+ ) {
+ item {
+ Card {
+ SuperSwitch(
+ title = "启动后关闭屏幕",
+ summary = "--turn-screen-off",
+ checked = turnScreenOff,
+ onCheckedChange = { value ->
+ onTurnScreenOffChange(value)
+ if (value) scope.launch {
+ // github.com/Genymobile/scrcpy/issues/3376
+ // github.com/Genymobile/scrcpy/issues/4587
+ // github.com/Genymobile/scrcpy/issues/5676
+ snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半")
+ }
+ },
+ enabled = !sessionStarted && !noControl,
+ )
+ SuperSwitch(
+ title = "禁用控制",
+ summary = "--no-control",
+ checked = noControl,
+ onCheckedChange = onNoControlChange,
+ enabled = !sessionStarted,
+ )
+ SuperSwitch(
+ title = "禁用视频",
+ summary = "--no-video",
+ checked = noVideo,
+ onCheckedChange = onNoVideoChange,
+ enabled = !sessionStarted,
+ )
+ }
+ }
+
+ item {
+ Card {
+ SuperDropdown(
+ title = "视频来源",
+ summary = "--video-source",
+ items = videoSourceItems,
+ selectedIndex = videoSourceIndex,
+ onSelectedIndexChange = { index ->
+ onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first)
+ },
+ enabled = !sessionStarted,
+ )
+ if (videoSourcePreset == "display") {
+ TextField(
+ value = displayIdInput,
+ onValueChange = onDisplayIdInputChange,
+ label = "--display-id",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ }
+ if (videoSourcePreset == "camera") {
+ TextField(
+ value = cameraIdInput,
+ onValueChange = onCameraIdInputChange,
+ label = "--camera-id",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ SuperArrow(
+ title = "重新获取 Camera Sizes",
+ summary = "--list-camera-sizes",
+ onClick = onRefreshCameraSizes,
+ enabled = !sessionStarted,
+ )
+ SuperDropdown(
+ title = "摄像头朝向",
+ summary = "--camera-facing",
+ items = cameraFacingItems,
+ selectedIndex = cameraFacingIndex,
+ onSelectedIndexChange = { index ->
+ onCameraFacingPresetChange(CAMERA_FACING_OPTIONS[index].first)
+ },
+ enabled = !sessionStarted,
+ )
+ SuperDropdown(
+ title = "摄像头分辨率",
+ summary = "--camera-size",
+ items = cameraSizeDropdownItems,
+ selectedIndex = cameraSizeIndex.coerceIn(0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0)),
+ onSelectedIndexChange = { index ->
+ onCameraSizePresetChange(
+ when (index) {
+ 0 -> ""
+ cameraSizeDropdownItems.lastIndex -> "custom"
+ else -> cameraSizeDropdownItems[index]
+ },
+ )
+ },
+ enabled = !sessionStarted,
+ )
+ if (cameraSizePreset == "custom") {
+ TextField(
+ value = cameraSizeCustom,
+ onValueChange = onCameraSizeCustomChange,
+ label = "--camera-size",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ }
+ TextField(
+ value = cameraArInput,
+ onValueChange = onCameraArInputChange,
+ label = "--camera-ar",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ SuperSlide(
+ title = "摄像头帧率",
+ summary = "--camera-fps",
+ value = cameraFpsPresetIndex.toFloat(),
+ onValueChange = { value ->
+ val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex)
+ val preset = CAMERA_FPS_PRESETS[idx]
+ onCameraFpsInputChange(if (preset == 0) "" else preset.toString())
+ },
+ valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(),
+ steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0),
+ enabled = !sessionStarted,
+ unit = "fps",
+ zeroStateText = "默认",
+ showUnitWhenZeroState = false,
+ showKeyPoints = true,
+ keyPoints = CAMERA_FPS_PRESETS.indices.map { it.toFloat() },
+ displayText = cameraFpsInput,
+ inputHint = "0 或留空表示默认",
+ inputInitialValue = cameraFpsInput,
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 0f..Float.MAX_VALUE,
+ onInputConfirm = {
+ val normalized = it.ifBlank { "" }
+ onCameraFpsInputChange(if (normalized == "0") "" else normalized)
+ },
+ )
+ SuperSwitch(
+ title = "高帧率模式",
+ summary = "--camera-high-speed",
+ checked = cameraHighSpeed,
+ onCheckedChange = onCameraHighSpeedChange,
+ enabled = !sessionStarted,
+ )
+ }
+ }
+ }
+
+ item {
+ Card {
+ SuperDropdown(
+ title = "音频来源",
+ summary = "--audio-source",
+ items = audioSourceItems,
+ selectedIndex = audioSourceIndex,
+ onSelectedIndexChange = { index ->
+ onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first)
+ },
+ enabled = !sessionStarted && audioEnabled,
+ )
+ if (audioSourcePreset == "custom") {
+ TextField(
+ value = audioSourceCustom,
+ onValueChange = onAudioSourceCustomChange,
+ label = "--audio-source",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ }
+ SuperSwitch(
+ title = "音频双路输出",
+ summary = "--audio-dup",
+ checked = audioDup,
+ onCheckedChange = onAudioDupChange,
+ enabled = !sessionStarted && audioEnabled,
+ )
+ SuperSwitch(
+ title = "仅转发不播放",
+ summary = "--no-audio-playback",
+ checked = noAudioPlayback,
+ onCheckedChange = onNoAudioPlaybackChange,
+ enabled = !sessionStarted && audioEnabled,
+ )
+ SuperSwitch(
+ title = "音频失败时终止 [TODO]",
+ summary = "--require-audio",
+ checked = requireAudio,
+ onCheckedChange = onRequireAudioChange,
+ enabled = false,
+ )
+ }
+ }
+
+ item {
+ Card {
+ SuperSlide(
+ title = "最大分辨率",
+ summary = "--max-size",
+ value = maxSizePresetIndex.toFloat(),
+ onValueChange = { value ->
+ val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
+ val preset = ScrcpyPresets.MaxSize[idx]
+ onMaxSizeInputChange(if (preset == 0) "" else preset.toString())
+ },
+ valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(),
+ steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0),
+ enabled = !sessionStarted,
+ unit = "px",
+ zeroStateText = "关闭",
+ showUnitWhenZeroState = false,
+ showKeyPoints = true,
+ keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() },
+ displayText = maxSizeInput,
+ inputHint = "0 或留空表示关闭",
+ inputInitialValue = maxSizeInput,
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 0f..Float.MAX_VALUE,
+ onInputConfirm = {
+ val normalized = it.ifBlank { "" }
+ onMaxSizeInputChange(normalized)
+ },
+ )
+ SuperSlide(
+ title = "最大帧率",
+ summary = "--max-fps",
+ value = maxFpsPresetIndex.toFloat(),
+ onValueChange = { value ->
+ val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex)
+ val preset = ScrcpyPresets.MaxFPS[idx]
+ onMaxFpsInputChange(if (preset == 0) "" else preset.toString())
+ },
+ valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(),
+ steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0),
+ enabled = !sessionStarted,
+ unit = "fps",
+ zeroStateText = "关闭",
+ showUnitWhenZeroState = false,
+ showKeyPoints = true,
+ keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() },
+ displayText = maxFpsInput,
+ inputHint = "0 或留空表示关闭",
+ inputInitialValue = maxFpsInput,
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 0f..Float.MAX_VALUE,
+ onInputConfirm = {
+ val normalized = it.ifBlank { "" }
+ onMaxFpsInputChange(normalized)
+ },
+ )
+ }
+ }
+
+ item {
+ Card {
+ SuperArrow(
+ title = "重新获取编码器列表",
+ summary = "--list-encoders",
+ onClick = onRefreshEncoders,
+ enabled = !sessionStarted,
+ )
+ SuperSpinner(
+ title = "视频编码器",
+ summary = "--video-encoder",
+ items = videoEncoderEntries,
+ selectedIndex = videoEncoderIndex,
+ onSelectedIndexChange = { index ->
+ onVideoEncoderChange(if (index == 0) "" else videoEncoderDropdownItems[index])
+ },
+ enabled = !sessionStarted,
+ )
+ TextField(
+ value = videoCodecOptions,
+ onValueChange = onVideoCodecOptionsChange,
+ label = "--video-codec-options",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ SuperSpinner(
+ title = "音频编码器",
+ summary = "--audio-encoder",
+ items = audioEncoderEntries,
+ selectedIndex = audioEncoderIndex,
+ onSelectedIndexChange = { index ->
+ onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index])
+ },
+ enabled = !sessionStarted && audioEnabled,
+ )
+ TextField(
+ value = audioCodecOptions,
+ onValueChange = onAudioCodecOptionsChange,
+ label = "--audio-codec-options",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ }
+ }
+
+ item {
+ Card {
+ Text(
+ text = "--new-display",
+ modifier = Modifier
+ .padding(horizontal = UiSpacing.CardTitle)
+ .padding(
+ top = UiSpacing.CardContent,
+ bottom = UiSpacing.FieldLabelBottom,
+ ),
+ fontWeight = FontWeight.Medium,
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ TextField(
+ value = newDisplayWidth,
+ onValueChange = onNewDisplayWidthChange,
+ label = "width",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Next) },
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ TextField(
+ value = newDisplayHeight,
+ onValueChange = onNewDisplayHeightChange,
+ label = "height",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Next) },
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ TextField(
+ value = newDisplayDpi,
+ onValueChange = onNewDisplayDpiChange,
+ label = "dpi",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = { focusManager.clearFocus() },
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+
+ item {
+ Card {
+ Text(
+ text = "--crop",
+ modifier = Modifier
+ .padding(horizontal = UiSpacing.CardTitle)
+ .padding(
+ top = UiSpacing.CardContent,
+ bottom = UiSpacing.FieldLabelBottom,
+ ),
+ fontWeight = FontWeight.Medium,
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ TextField(
+ value = cropWidth,
+ onValueChange = onCropWidthChange,
+ label = "width",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Next) },
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ TextField(
+ value = cropHeight,
+ onValueChange = onCropHeightChange,
+ label = "height",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Next) },
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ TextField(
+ value = cropX,
+ onValueChange = onCropXChange,
+ label = "x",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Next,
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Next) },
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ TextField(
+ value = cropY,
+ onValueChange = onCropYChange,
+ label = "y",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = { focusManager.clearFocus() },
+ ),
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+ }
+
+ // TODO: 放进 [AppPageLazyColumn] 里
+ item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
+ }
+}
+
+private fun presetIndexFromInputForAdvancedPage(raw: String, presets: List): Int {
+ if (raw.isBlank()) return 0
+ val value = raw.toIntOrNull() ?: return 0
+ val exact = presets.indexOf(value)
+ if (exact >= 0) return exact
+ val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) }
+ return nearest?.index ?: 0
+}
+
+private fun resolveEncoderTypeLabel(raw: String?): String {
+ return when (raw?.trim()?.lowercase()) {
+ "hw" -> "hw"
+ "sw" -> "sw"
+ else -> ""
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt
new file mode 100644
index 0000000..86f515c
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt
@@ -0,0 +1,1080 @@
+package io.github.miuzarte.scrcpyforandroid.pages
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.util.Log
+import android.view.WindowManager
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.toMutableStateList
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.unit.sp
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DragIndicator
+import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
+import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
+import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
+import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
+import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
+import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
+import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings
+import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices
+import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
+import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget
+import io.github.miuzarte.scrcpyforandroid.services.saveQuickDevices
+import io.github.miuzarte.scrcpyforandroid.services.saveDevicePageSettings
+import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty
+import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice
+import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
+import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
+import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
+import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
+import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
+import io.github.miuzarte.scrcpyforandroid.widgets.LogsPanel
+import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
+import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
+import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
+import io.github.miuzarte.scrcpyforandroid.widgets.SortDropPayload
+import io.github.miuzarte.scrcpyforandroid.widgets.SortTransferDirection
+import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardItem
+import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardList
+import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import top.yukonga.miuix.kmp.basic.ScrollBehavior
+import top.yukonga.miuix.kmp.basic.SnackbarHostState
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.extra.SuperBottomSheet
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import kotlin.math.roundToInt
+
+private const val ADB_CONNECT_TIMEOUT_MS = 3_000L
+private const val ADB_KEEPALIVE_INTERVAL_MS = 15_000L
+private const val ADB_KEEPALIVE_TIMEOUT_MS = 2_000L
+private const val DEVICE_SHORTCUT_SEPARATOR = "\u001F"
+private const val LOG_TAG = "DevicePage"
+
+private val DeviceShortcutStateListSaver = listSaver, String>(
+ save = { list ->
+ list.map { item ->
+ listOf(
+ item.id,
+ item.name,
+ item.host,
+ item.port.toString(),
+ if (item.online) "1" else "0",
+ ).joinToString(DEVICE_SHORTCUT_SEPARATOR)
+ }
+ },
+ restore = { saved ->
+ saved.mapNotNull { line ->
+ val parts = line.split(DEVICE_SHORTCUT_SEPARATOR)
+ if (parts.size != 5) return@mapNotNull null
+ val port = parts[3].toIntOrNull() ?: return@mapNotNull null
+ DeviceShortcut(
+ id = parts[0],
+ name = parts[1],
+ host = parts[2],
+ port = port,
+ online = parts[4] == "1",
+ )
+ }.toMutableStateList()
+ },
+)
+
+private val StringStateListSaver = listSaver, String>(
+ save = { it.toList() },
+ restore = { it.toMutableStateList() },
+)
+
+@Composable
+fun DeviceTabScreen(
+ contentPadding: PaddingValues,
+ nativeCore: NativeCoreFacade,
+ snack: SnackbarHostState,
+ scrollBehavior: ScrollBehavior,
+ keepScreenOnWhenStreamingEnabled: Boolean,
+ virtualButtonsOutside: List,
+ virtualButtonsInMore: List,
+ previewCardHeightDp: Int,
+ customServerUri: String?,
+ serverRemotePath: String,
+ onServerRemotePathChange: (String) -> Unit,
+ videoCodec: String,
+ onVideoCodecChange: (String) -> Unit,
+ audioEnabled: Boolean,
+ onAudioEnabledChange: (Boolean) -> Unit,
+ audioCodec: String,
+ onAudioCodecChange: (String) -> Unit,
+ noControl: Boolean,
+ onNoControlChange: (Boolean) -> Unit,
+ videoEncoder: String,
+ onVideoEncoderChange: (String) -> Unit,
+ videoCodecOptions: String,
+ onVideoCodecOptionsChange: (String) -> Unit,
+ audioEncoder: String,
+ onAudioEncoderChange: (String) -> Unit,
+ audioCodecOptions: String,
+ onAudioCodecOptionsChange: (String) -> Unit,
+ audioDup: Boolean,
+ onAudioDupChange: (Boolean) -> Unit,
+ audioSourcePreset: String,
+ onAudioSourcePresetChange: (String) -> Unit,
+ audioSourceCustom: String,
+ onAudioSourceCustomChange: (String) -> Unit,
+ videoSourcePreset: String,
+ onVideoSourcePresetChange: (String) -> Unit,
+ cameraIdInput: String,
+ onCameraIdInputChange: (String) -> Unit,
+ cameraFacingPreset: String,
+ onCameraFacingPresetChange: (String) -> Unit,
+ cameraSizePreset: String,
+ onCameraSizePresetChange: (String) -> Unit,
+ cameraSizeCustom: String,
+ onCameraSizeCustomChange: (String) -> Unit,
+ cameraArInput: String,
+ onCameraArInputChange: (String) -> Unit,
+ cameraFpsInput: String,
+ onCameraFpsInputChange: (String) -> Unit,
+ cameraHighSpeed: Boolean,
+ onCameraHighSpeedChange: (Boolean) -> Unit,
+ noAudioPlayback: Boolean,
+ onNoAudioPlaybackChange: (Boolean) -> Unit,
+ noVideo: Boolean,
+ requireAudio: Boolean,
+ onRequireAudioChange: (Boolean) -> Unit,
+ turnScreenOff: Boolean,
+ onTurnScreenOffChange: (Boolean) -> Unit,
+ maxSizeInput: String,
+ onMaxSizeInputChange: (String) -> Unit,
+ maxFpsInput: String,
+ onMaxFpsInputChange: (String) -> Unit,
+ newDisplayWidth: String,
+ onNewDisplayWidthChange: (String) -> Unit,
+ newDisplayHeight: String,
+ onNewDisplayHeightChange: (String) -> Unit,
+ newDisplayDpi: String,
+ onNewDisplayDpiChange: (String) -> Unit,
+ displayIdInput: String,
+ onDisplayIdInputChange: (String) -> Unit,
+ cropWidth: String,
+ onCropWidthChange: (String) -> Unit,
+ cropHeight: String,
+ onCropHeightChange: (String) -> Unit,
+ cropX: String,
+ onCropXChange: (String) -> Unit,
+ cropY: String,
+ onCropYChange: (String) -> Unit,
+ videoEncoderOptions: List,
+ onVideoEncoderOptionsChange: (List) -> Unit,
+ onVideoEncoderTypeMapChange: (Map) -> Unit,
+ audioEncoderOptions: List,
+ onAudioEncoderOptionsChange: (List) -> Unit,
+ onAudioEncoderTypeMapChange: (Map) -> Unit,
+ cameraSizeOptions: List,
+ onCameraSizeOptionsChange: (List) -> Unit,
+ onSessionStartedChange: (Boolean) -> Unit,
+ onRefreshEncodersActionChange: ((() -> Unit)?) -> Unit,
+ onRefreshCameraSizesActionChange: ((() -> Unit)?) -> Unit,
+ onClearLogsActionChange: ((() -> Unit)?) -> Unit,
+ onCanClearLogsChange: (Boolean) -> Unit,
+ onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit,
+ onOpenAdvancedPage: () -> Unit,
+ onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit,
+) {
+ val context = LocalContext.current
+ val haptics = rememberAppHaptics()
+ val virtualButtonLayout = remember(virtualButtonsOutside, virtualButtonsInMore) {
+ VirtualButtonActions.resolveLayout(virtualButtonsOutside, virtualButtonsInMore)
+ }
+ val activity = remember(context) { context as? Activity }
+ val initialSettings = remember(context) { loadDevicePageSettings(context) }
+ val scope = rememberCoroutineScope()
+
+ var busy by rememberSaveable { mutableStateOf(false) }
+ var statusLine by rememberSaveable { mutableStateOf("未连接") }
+ var adbConnected by rememberSaveable { mutableStateOf(false) }
+ var currentTargetHost by rememberSaveable { mutableStateOf("") }
+ var currentTargetPort by rememberSaveable { mutableIntStateOf(AppDefaults.DefaultAdbPort) }
+ var connectedDeviceLabel by rememberSaveable { mutableStateOf("未连接") }
+ var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) }
+ var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) }
+ var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") }
+ var sessionInfoCodec by rememberSaveable { mutableStateOf("") }
+ var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) }
+ var sessionInfo by remember {
+ mutableStateOf(null)
+ }
+ LaunchedEffect(sessionInfoWidth, sessionInfoHeight, sessionInfoDeviceName, sessionInfoCodec, sessionInfoControlEnabled) {
+ sessionInfo = if (sessionInfoDeviceName.isNotBlank()) {
+ ScrcpySessionInfo(
+ width = sessionInfoWidth,
+ height = sessionInfoHeight,
+ deviceName = sessionInfoDeviceName,
+ codec = sessionInfoCodec,
+ controlEnabled = sessionInfoControlEnabled,
+ )
+ } else {
+ null
+ }
+ }
+ var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
+ var editingDeviceId by rememberSaveable { mutableStateOf(null) }
+ var activeDeviceActionId by rememberSaveable { mutableStateOf(null) }
+ var showReorderSheet by rememberSaveable { mutableStateOf(false) }
+ var adbConnecting by rememberSaveable { mutableStateOf(false) }
+
+ var connectHost by rememberSaveable { mutableStateOf("") }
+ var connectPort by rememberSaveable { mutableStateOf(AppDefaults.DefaultAdbPort.toString()) }
+ var quickConnectInput by rememberSaveable { mutableStateOf(initialSettings.quickConnectInput) }
+ var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
+ var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
+
+ var bitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.bitRateMbps) }
+ var bitRateInput by rememberSaveable { mutableStateOf(initialSettings.bitRateInput) }
+ var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) }
+ val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget(currentTargetHost, currentTargetPort) else null
+
+ val eventLog = rememberSaveable(saver = StringStateListSaver) { mutableStateListOf() }
+ val quickDevices = rememberSaveable(saver = DeviceShortcutStateListSaver) { mutableStateListOf() }
+
+ LaunchedEffect(eventLog.size) {
+ onCanClearLogsChange(eventLog.isNotEmpty())
+ }
+
+ fun logEvent(message: String, level: Int = Log.INFO, error: Throwable? = null) {
+ val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
+ val line = "[$timestamp] $message"
+ eventLog.add(0, line)
+ if (eventLog.size > AppDefaults.MaxEventLogLines) {
+ eventLog.removeRange(AppDefaults.MaxEventLogLines, eventLog.size)
+ }
+ when (level) {
+ Log.ERROR -> if (error != null) Log.e(LOG_TAG, message, error) else Log.e(LOG_TAG, message)
+ Log.WARN -> if (error != null) Log.w(LOG_TAG, message, error) else Log.w(LOG_TAG, message)
+ Log.DEBUG -> if (error != null) Log.d(LOG_TAG, message, error) else Log.d(LOG_TAG, message)
+ else -> if (error != null) Log.i(LOG_TAG, message, error) else Log.i(LOG_TAG, message)
+ }
+ }
+
+ fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
+ val audioSupported = sdkInt !in 0..<30
+ audioForwardingSupported = audioSupported
+ if (!audioSupported && audioEnabled) {
+ onAudioEnabledChange(false)
+ logEvent("设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", Log.WARN)
+ }
+ val cameraSupported = sdkInt !in 0..<31
+ cameraMirroringSupported = cameraSupported
+ if (!cameraSupported && videoSourcePreset == "camera") {
+ onVideoSourcePresetChange("display")
+ logEvent("设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", Log.WARN)
+ }
+ }
+
+ suspend fun connectWithTimeout(host: String, port: Int): Boolean {
+ return withContext(Dispatchers.IO) {
+ withTimeout(ADB_CONNECT_TIMEOUT_MS) {
+ nativeCore.adbConnect(host, port)
+ }
+ }
+ }
+
+ suspend fun keepAliveCheck(host: String, port: Int): Boolean {
+ return withContext(Dispatchers.IO) {
+ withTimeout(ADB_KEEPALIVE_TIMEOUT_MS) {
+ val connected = nativeCore.adbIsConnected()
+ if (!connected) {
+ return@withTimeout false
+ }
+ runCatching {
+ nativeCore.adbShell("echo -n 1")
+ true
+ }.getOrElse { false }
+ }
+ }
+ }
+
+ fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
+ if (busy) return
+ scope.launch {
+ busy = true
+ try {
+ block()
+ } catch (_: TimeoutCancellationException) {
+ logEvent("$label 超时", Log.WARN)
+ } catch (e: IllegalArgumentException) {
+ val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
+ logEvent("$label 参数错误: $detail", Log.WARN, e)
+ snack.showSnackbar("$label 参数错误: $detail")
+ } catch (e: Exception) {
+ val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
+ logEvent("$label 失败: $detail", Log.ERROR, e)
+ } finally {
+ busy = false
+ onFinished?.invoke()
+ }
+ }
+ }
+
+ fun runAdbConnect(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
+ if (adbConnecting) return
+ scope.launch {
+ adbConnecting = true
+ try {
+ block()
+ } catch (_: TimeoutCancellationException) {
+ logEvent("$label 超时", Log.WARN)
+ } catch (e: IllegalArgumentException) {
+ val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
+ logEvent("$label 参数错误: $detail", Log.WARN, e)
+ snack.showSnackbar("$label 参数错误: $detail")
+ } catch (e: Exception) {
+ val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
+ logEvent("$label 失败: $detail", Log.ERROR, e)
+ } finally {
+ adbConnecting = false
+ onFinished?.invoke()
+ }
+ }
+ }
+
+ fun refreshEncoderLists() {
+ if (!adbConnected) return
+ val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.DefaultServerRemotePath }
+ runCatching {
+ nativeCore.scrcpyListEncoders(
+ customServerUri = customServerUri,
+ remotePath = remotePath,
+ )
+ }.onSuccess { lists ->
+ onVideoEncoderOptionsChange(lists.videoEncoders)
+ onAudioEncoderOptionsChange(lists.audioEncoders)
+ onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
+ onAudioEncoderTypeMapChange(lists.audioEncoderTypes)
+ if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) {
+ onVideoEncoderChange("")
+ }
+ if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
+ onAudioEncoderChange("")
+ }
+ logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
+ if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) {
+ logEvent("提示: 编码器为空,请检查 server 路径/版本与设备系统日志", Log.WARN)
+ val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
+ if (preview.isNotBlank()) {
+ logEvent("编码器原始输出: $preview", Log.DEBUG)
+ }
+ }
+ }.onFailure { e ->
+ onVideoEncoderOptionsChange(emptyList())
+ onAudioEncoderOptionsChange(emptyList())
+ onVideoEncoderTypeMapChange(emptyMap())
+ onAudioEncoderTypeMapChange(emptyMap())
+ logEvent("读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e)
+ }
+ }
+
+ fun refreshCameraSizeLists() {
+ if (!adbConnected) return
+ val remotePath = serverRemotePath.trim().ifBlank { AppDefaults.DefaultServerRemotePath }
+ runCatching {
+ nativeCore.scrcpyListCameraSizes(
+ customServerUri = customServerUri,
+ remotePath = remotePath,
+ )
+ }.onSuccess { lists ->
+ onCameraSizeOptionsChange(lists.sizes)
+ if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) {
+ onCameraSizePresetChange("")
+ }
+ logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
+ if (lists.sizes.isEmpty()) {
+ val preview = lists.rawOutput.lineSequence().take(20).joinToString(" | ")
+ if (preview.isNotBlank()) {
+ logEvent("camera sizes 原始输出: $preview", Log.DEBUG)
+ }
+ }
+ }.onFailure { e ->
+ onCameraSizeOptionsChange(emptyList())
+ logEvent("读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", Log.ERROR, e)
+ }
+ }
+
+ fun handleAdbConnected(host: String, port: Int) {
+ currentTargetHost = host
+ currentTargetPort = port
+
+ val info = fetchConnectedDeviceInfo(nativeCore, host, port)
+ val fullLabel = if (info.serial.isNotBlank()) {
+ "${info.model} (${info.serial})"
+ } else {
+ info.model
+ }
+
+ connectedDeviceLabel = info.model
+ applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
+ updateQuickDeviceNameIfEmpty(context, quickDevices, host, port, fullLabel)
+ connectHost = host
+ connectPort = port.toString()
+ statusLine = "$host:$port"
+
+ logEvent("ADB 已连接: model=${info.model}, serial=${info.serial.ifBlank { "unknown" }}, manufacturer=${info.manufacturer.ifBlank { "unknown" }}, brand=${info.brand.ifBlank { "unknown" }}, device=${info.device.ifBlank { "unknown" }}, android=${info.androidRelease.ifBlank { "unknown" }}, sdk=${info.sdkInt}")
+ scope.launch {
+ snack.showSnackbar("ADB 已连接")
+ }
+ refreshEncoderLists()
+ refreshCameraSizeLists()
+ }
+
+ LaunchedEffect(bitRateInput) {
+ val parsed = bitRateInput.toFloatOrNull() ?: return@LaunchedEffect
+ bitRateMbps = parsed.coerceAtLeast(0.1f)
+ }
+
+ LaunchedEffect(
+ quickConnectInput,
+ bitRateMbps,
+ bitRateInput,
+ audioBitRateKbps,
+ maxSizeInput,
+ maxFpsInput,
+ noControl,
+ videoEncoder,
+ videoCodecOptions,
+ audioEncoder,
+ audioCodecOptions,
+ audioDup,
+ audioSourcePreset,
+ audioSourceCustom,
+ videoSourcePreset,
+ cameraIdInput,
+ cameraFacingPreset,
+ cameraSizePreset,
+ cameraSizeCustom,
+ cameraArInput,
+ cameraFpsInput,
+ cameraHighSpeed,
+ noAudioPlayback,
+ noVideo,
+ requireAudio,
+ turnScreenOff,
+ newDisplayWidth,
+ newDisplayHeight,
+ newDisplayDpi,
+ displayIdInput,
+ cropWidth,
+ cropHeight,
+ cropX,
+ cropY,
+ ) {
+ saveDevicePageSettings(
+ context,
+ DevicePageSettings(
+ quickConnectInput = quickConnectInput,
+ bitRateMbps = bitRateMbps,
+ bitRateInput = bitRateInput,
+ audioBitRateKbps = audioBitRateKbps,
+ maxSizeInput = maxSizeInput,
+ maxFpsInput = maxFpsInput,
+ noControl = noControl,
+ videoEncoder = videoEncoder,
+ videoCodecOptions = videoCodecOptions,
+ audioEncoder = audioEncoder,
+ audioCodecOptions = audioCodecOptions,
+ audioDup = audioDup,
+ audioSourcePreset = audioSourcePreset,
+ audioSourceCustom = audioSourceCustom,
+ videoSourcePreset = videoSourcePreset,
+ cameraIdInput = cameraIdInput,
+ cameraFacingPreset = cameraFacingPreset,
+ cameraSizePreset = cameraSizePreset,
+ cameraSizeCustom = cameraSizeCustom,
+ cameraArInput = cameraArInput,
+ cameraFpsInput = cameraFpsInput,
+ cameraHighSpeed = cameraHighSpeed,
+ noAudioPlayback = noAudioPlayback,
+ noVideo = noVideo,
+ requireAudio = requireAudio,
+ turnScreenOff = turnScreenOff,
+ newDisplayWidth = newDisplayWidth,
+ newDisplayHeight = newDisplayHeight,
+ newDisplayDpi = newDisplayDpi,
+ displayIdInput = displayIdInput,
+ cropWidth = cropWidth,
+ cropHeight = cropHeight,
+ cropX = cropX,
+ cropY = cropY,
+ ),
+ )
+ }
+
+ LaunchedEffect(Unit) {
+ if (quickDevices.isEmpty()) {
+ quickDevices.clear()
+ quickDevices.addAll(loadQuickDevices(context))
+ }
+ }
+
+ LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, quickDevices.size) {
+ val activeId = if (adbConnected && currentTargetHost.isNotBlank()) {
+ "$currentTargetHost:$currentTargetPort"
+ } else {
+ null
+ }
+ for (index in quickDevices.indices) {
+ val item = quickDevices[index]
+ val shouldOnline = activeId != null && item.id == activeId
+ if (item.online != shouldOnline) {
+ quickDevices[index] = item.copy(online = shouldOnline)
+ }
+ }
+ }
+
+ LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) {
+ if (!adbConnected || currentTargetHost.isBlank()) return@LaunchedEffect
+
+ val host = currentTargetHost
+ val port = currentTargetPort
+ while (adbConnected && currentTargetHost == host && currentTargetPort == port) {
+ delay(ADB_KEEPALIVE_INTERVAL_MS)
+ val alive = runCatching { keepAliveCheck(host, port) }.getOrElse { false }
+ if (alive) continue
+
+ logEvent("ADB 长连接中断,尝试自动重连: $host:$port", Log.WARN)
+ val reconnected = runCatching { connectWithTimeout(host, port) }.getOrElse { false }
+ adbConnected = reconnected
+ if (reconnected) {
+ statusLine = "$host:$port"
+ logEvent("ADB 自动重连成功: $host:$port")
+ scope.launch {
+ snack.showSnackbar("ADB 自动重连成功")
+ }
+ } else {
+ statusLine = "ADB 连接断开"
+ connectedDeviceLabel = "未连接"
+ sessionInfo = null
+ logEvent("ADB 自动重连失败: $host:$port", Log.ERROR)
+ scope.launch {
+ snack.showSnackbar("ADB 自动重连失败")
+ }
+ break
+ }
+ }
+ }
+
+ DisposableEffect(nativeCore) {
+ val listener: (Int, Int) -> Unit = { width, height ->
+ sessionInfo = sessionInfo?.copy(width = width, height = height)
+ }
+ nativeCore.addVideoSizeListener(listener)
+ onDispose {
+ nativeCore.removeVideoSizeListener(listener)
+ }
+ }
+
+ DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionInfo != null) {
+ val window = activity?.window
+ val shouldKeepScreenOn = keepScreenOnWhenStreamingEnabled && sessionInfo != null
+ if (window != null && shouldKeepScreenOn) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ onDispose {
+ if (window != null && shouldKeepScreenOn) {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+ }
+
+ LaunchedEffect(sessionInfo) {
+ if (sessionInfo != null) {
+ sessionInfoWidth = sessionInfo?.width ?: 0
+ sessionInfoHeight = sessionInfo?.height ?: 0
+ sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty()
+ sessionInfoCodec = sessionInfo?.codec.orEmpty()
+ sessionInfoControlEnabled = sessionInfo?.controlEnabled == true
+ } else {
+ sessionInfoWidth = 0
+ sessionInfoHeight = 0
+ sessionInfoDeviceName = ""
+ sessionInfoCodec = ""
+ sessionInfoControlEnabled = false
+ }
+ onSessionStartedChange(sessionInfo != null)
+ }
+
+ DisposableEffect(Unit) {
+ onRefreshEncodersActionChange {
+ runBusy("刷新编码器") { refreshEncoderLists() }
+ }
+ onRefreshCameraSizesActionChange {
+ runBusy("刷新 Camera Sizes") { refreshCameraSizeLists() }
+ }
+ onClearLogsActionChange {
+ eventLog.clear()
+ }
+ onOpenReorderDevicesActionChange {
+ showReorderSheet = true
+ }
+ onDispose {
+ onRefreshEncodersActionChange(null)
+ onRefreshCameraSizesActionChange(null)
+ onClearLogsActionChange(null)
+ onCanClearLogsChange(false)
+ onOpenReorderDevicesActionChange(null)
+ onSessionStartedChange(false)
+ }
+ }
+
+ SuperBottomSheet(
+ show = showReorderSheet,
+ title = "调整设备排序",
+ onDismissRequest = { showReorderSheet = false },
+ ) {
+ SortableCardList(
+ title = "设备列表",
+ items = quickDevices.map { device ->
+ SortableCardItem(
+ id = device.id,
+ title = device.name.ifBlank { device.host },
+ subtitle = "${device.host}:${device.port}",
+ )
+ },
+ modifier = Modifier.padding(horizontal = UiSpacing.CardContent),
+ transferDirection = SortTransferDirection.NONE,
+ onLongPressHaptic = { haptics.press() },
+ onDrop = { payload: SortDropPayload ->
+ val fromIndex = quickDevices.indexOfFirst { it.id == payload.itemId }
+ if (fromIndex < 0) return@SortableCardList
+ val steps = (payload.deltaY / 54f).roundToInt()
+ if (steps == 0) return@SortableCardList
+ val toIndex = (fromIndex + steps).coerceIn(0, quickDevices.lastIndex)
+ if (toIndex == fromIndex) return@SortableCardList
+ val moved = quickDevices.removeAt(fromIndex)
+ quickDevices.add(toIndex, moved)
+ saveQuickDevices(context, quickDevices)
+ },
+ )
+ Spacer(Modifier.height(UiSpacing.Large))
+ }
+
+ fun sendVirtualButtonAction(action: VirtualButtonAction) {
+ val keycode = action.keycode ?: return
+ runBusy("发送 ${action.title}") {
+ nativeCore.scrcpyInjectKeycode(0, keycode)
+ nativeCore.scrcpyInjectKeycode(1, keycode)
+ }
+ }
+
+ if (editingDeviceId != null) {
+ val device = quickDevices.firstOrNull { it.id == editingDeviceId }
+ if (device != null) {
+ DeviceEditorScreen(
+ contentPadding = contentPadding,
+ device = device,
+ onSave = { updated ->
+ val idx = quickDevices.indexOfFirst { it.id == device.id }
+ if (idx >= 0) {
+ quickDevices[idx] = updated.copy(online = quickDevices[idx].online)
+ saveQuickDevices(context, quickDevices)
+ }
+ editingDeviceId = null
+ },
+ onDelete = {
+ quickDevices.removeAll { it.id == device.id }
+ saveQuickDevices(context, quickDevices)
+ editingDeviceId = null
+ },
+ onBack = { editingDeviceId = null },
+ )
+ return
+ }
+ editingDeviceId = null
+ }
+
+ // 设备
+ AppPageLazyColumn(
+ contentPadding = contentPadding,
+ scrollBehavior = scrollBehavior,
+ ) {
+ item {
+ StatusCard(
+ statusLine = statusLine,
+ adbConnected = adbConnected,
+ streaming = sessionInfo != null,
+ sessionInfo = sessionInfo,
+ busyLabel = null,
+ connectedDeviceLabel = connectedDeviceLabel,
+ )
+ }
+
+ itemsIndexed(quickDevices, key = { _, device -> device.id }) { _, device ->
+ val host = device.host
+ val port = device.port
+ val isConnectedTarget = adbConnected && currentTarget?.host == host && currentTarget.port == port
+
+ DeviceTile(
+ device = device,
+ actionText = if (isConnectedTarget) "断开" else "连接",
+ actionEnabled = !busy && !adbConnecting,
+ actionInProgress = adbConnecting && activeDeviceActionId == device.id,
+ onLongPress = { editingDeviceId = device.id },
+ onContentClick = {
+ scope.launch {
+ snack.showSnackbar("长按可编辑设备")
+ }
+ },
+ onAction = {
+ haptics.press()
+ if (isConnectedTarget) {
+ activeDeviceActionId = device.id
+ runBusy("断开 ADB", onFinished = { activeDeviceActionId = null }) {
+ nativeCore.adbDisconnect()
+ adbConnected = false
+ currentTargetHost = ""
+ currentTargetPort = AppDefaults.DefaultAdbPort
+ audioForwardingSupported = true
+ cameraMirroringSupported = true
+ sessionInfo = null
+ statusLine = "未连接"
+ connectedDeviceLabel = "未连接"
+ upsertQuickDevice(context, quickDevices, host, port, false)
+ logEvent("ADB 已断开: ${device.name}")
+ scope.launch {
+ snack.showSnackbar("ADB 已断开")
+ }
+ }
+ } else {
+ activeDeviceActionId = device.id
+ runAdbConnect("连接 ADB", onFinished = { activeDeviceActionId = null }) {
+ val ok = connectWithTimeout(host, port)
+ adbConnected = ok
+ upsertQuickDevice(context, quickDevices, host, port, ok)
+ if (ok) {
+ handleAdbConnected(host, port)
+ } else {
+ statusLine = "ADB 连接失败"
+ logEvent("ADB 连接失败: $host:$port", Log.ERROR)
+ scope.launch {
+ snack.showSnackbar("ADB 连接失败")
+ }
+ }
+ }
+ }
+ },
+ )
+ }
+
+ if (!adbConnected) item {
+ // "快速连接"
+ QuickConnectCard(
+ input = quickConnectInput,
+ onInputChange = { quickConnectInput = it },
+ enabled = !adbConnecting,
+ onAddDevice = {
+ val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
+ upsertQuickDevice(context, quickDevices, target.host, target.port, online = false)
+ scope.launch {
+ snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
+ }
+ },
+ onConnect = {
+ val target = parseQuickTarget(quickConnectInput) ?: return@QuickConnectCard
+ runAdbConnect("连接 ADB") {
+ val ok = connectWithTimeout(target.host, target.port)
+ adbConnected = ok
+ upsertQuickDevice(context, quickDevices, target.host, target.port, ok)
+ if (ok) {
+ handleAdbConnected(target.host, target.port)
+ } else {
+ statusLine = "ADB 连接失败"
+ logEvent("ADB 连接失败: ${target.host}:${target.port}", Log.ERROR)
+ scope.launch {
+ snack.showSnackbar("ADB 连接失败")
+ }
+ }
+ }
+ },
+ )
+ SectionSmallTitle(text = "无线配对", showLeadingSpacer = false)
+ // "使用配对码配对设备"
+ PairingCard(
+ busy = busy,
+ onPair = { host, port, code ->
+ runBusy("执行配对") {
+ val ok = nativeCore.adbPair(
+ host.trim(),
+ port.toIntOrNull() ?: AppDefaults.DefaultAdbPort,
+ code.trim(),
+ )
+ logEvent(if (ok) "配对成功" else "配对失败", if (ok) Log.INFO else Log.ERROR)
+ scope.launch {
+ snack.showSnackbar(if (ok) "配对成功" else "配对失败")
+ }
+ }
+ },
+ )
+ }
+
+ if (adbConnected) {
+ item {
+ ConfigPanel(
+ busy = busy,
+ bitRateMbps = bitRateMbps,
+ onBitRateSliderChange = {
+ bitRateMbps = it
+ @SuppressLint("DefaultLocale")
+ bitRateInput = String.format("%.1f", it)
+ },
+ onBitRateInputChange = { bitRateInput = it },
+ audioBitRateKbps = audioBitRateKbps,
+ onAudioBitRateChange = { audioBitRateKbps = it },
+ videoCodec = videoCodec,
+ onVideoCodecChange = onVideoCodecChange,
+ audioEnabled = audioEnabled,
+ onAudioEnabledChange = onAudioEnabledChange,
+ audioForwardingSupported = audioForwardingSupported,
+ audioCodec = audioCodec,
+ onAudioCodecChange = onAudioCodecChange,
+ onOpenAdvanced = onOpenAdvancedPage,
+ onStartStopHaptic = { haptics.press() },
+ onStart = {
+ runBusy("启动 scrcpy") {
+ if (noVideo && !audioEnabled) {
+ throw IllegalArgumentException("--no-video 需要同时启用音频")
+ }
+ if (audioEnabled && audioSourcePreset == "custom" && audioSourceCustom.isBlank()) {
+ throw IllegalArgumentException("audio-source 选择自定义时不能为空")
+ }
+ val resolvedVideoSource = videoSourcePreset.trim().ifBlank { "display" }
+ if (resolvedVideoSource == "camera" && !cameraMirroringSupported) {
+ throw IllegalArgumentException("camera mirroring 需要 Android 12+ (SDK 31+)")
+ }
+ val resolvedCameraSize = when (cameraSizePreset) {
+ "custom" -> cameraSizeCustom.trim()
+ else -> cameraSizePreset.trim()
+ }
+ if (resolvedVideoSource == "camera" && cameraSizePreset == "custom" && resolvedCameraSize.isBlank()) {
+ throw IllegalArgumentException("camera-size 选择自定义时不能为空")
+ }
+ val resolvedCameraId = cameraIdInput.trim()
+ val resolvedCameraFacing = cameraFacingPreset.trim()
+ if (resolvedVideoSource == "camera" && resolvedCameraId.isNotBlank() && resolvedCameraFacing.isNotBlank()) {
+ throw IllegalArgumentException("camera-id 与 camera-facing 不能同时设置")
+ }
+ val resolvedCameraAr = cameraArInput.trim()
+ val resolvedCameraFps =
+ cameraFpsInput.filter(Char::isDigit).toIntOrNull() ?: 0
+ if (resolvedVideoSource == "camera" && cameraHighSpeed && resolvedCameraFps <= 0) {
+ throw IllegalArgumentException("启用 --camera-high-speed 时,--camera-fps 不能为 0")
+ }
+ val maxSize =
+ maxSizeInput.filter(Char::isDigit).toIntOrNull()?.takeIf { it > 0 }
+ ?: 0
+ val maxFps =
+ maxFpsInput.filter(Char::isDigit).toIntOrNull()?.toFloat() ?: 0f
+ if (resolvedVideoSource == "camera" && resolvedCameraSize.isNotBlank() && (maxSize > 0 || resolvedCameraAr.isNotBlank())) {
+ throw IllegalArgumentException("显式 camera-size 时不能同时设置 --max-size 或 --camera-ar")
+ }
+ val bitRateBps = (bitRateMbps * 1_000_000).toInt()
+ val audioBitRateBps = (audioBitRateKbps.coerceAtLeast(1)) * 1_000
+ val resolvedAudioSource = when (audioSourcePreset) {
+ "custom" -> audioSourceCustom.trim()
+ else -> audioSourcePreset.trim()
+ }
+ val newDisplayArg = buildNewDisplayArg(
+ newDisplayWidth.filter(Char::isDigit),
+ newDisplayHeight.filter(Char::isDigit),
+ newDisplayDpi.filter(Char::isDigit),
+ )
+ val displayId = displayIdInput.filter(Char::isDigit).toIntOrNull()
+ ?.takeIf { it > 0 }
+ val crop = buildCropArg(
+ cropWidth.filter(Char::isDigit),
+ cropHeight.filter(Char::isDigit),
+ cropX.filter(Char::isDigit),
+ cropY.filter(Char::isDigit),
+ )
+ val effectiveTurnScreenOff = turnScreenOff && !noControl
+ val session = nativeCore.scrcpyStart(
+ NativeCoreFacade.defaultStartRequest(
+ customServerUri = customServerUri,
+ maxSize = maxSize,
+ maxFps = maxFps,
+ videoBitRate = bitRateBps,
+ remotePath = serverRemotePath.trim(),
+ videoCodec = videoCodec,
+ audio = audioEnabled,
+ audioCodec = audioCodec,
+ audioBitRate = audioBitRateBps,
+ noControl = noControl,
+ videoEncoder = videoEncoder,
+ videoCodecOptions = videoCodecOptions,
+ audioEncoder = audioEncoder,
+ audioCodecOptions = audioCodecOptions,
+ audioDup = audioDup,
+ audioSource = resolvedAudioSource,
+ videoSource = resolvedVideoSource,
+ cameraId = resolvedCameraId,
+ cameraFacing = resolvedCameraFacing,
+ cameraSize = resolvedCameraSize,
+ cameraAr = resolvedCameraAr,
+ cameraFps = resolvedCameraFps,
+ cameraHighSpeed = cameraHighSpeed,
+ noAudioPlayback = noAudioPlayback,
+ noVideo = noVideo,
+ requireAudio = requireAudio,
+ turnScreenOff = effectiveTurnScreenOff,
+ newDisplay = newDisplayArg,
+ displayId = displayId,
+ crop = crop,
+ ),
+ )
+ sessionInfo = session
+ statusLine = "scrcpy 运行中"
+ @SuppressLint("DefaultLocale")
+ val videoDetail = if (noVideo) {
+ "off"
+ } else {
+ "${session.codec} ${session.width}x${session.height} @${
+ String.format(
+ "%.1f",
+ bitRateMbps
+ )
+ }Mbps"
+ }
+ val audioDetail = if (!audioEnabled) {
+ "off"
+ } else {
+ val playback = if (noAudioPlayback) "(no-playback)" else ""
+ "$audioCodec ${audioBitRateKbps}kbps source=${resolvedAudioSource.ifBlank { "default" }}$playback"
+ }
+ logEvent("scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, control=${!noControl}, turnScreenOff=$effectiveTurnScreenOff, maxSize=${if (maxSize > 0) maxSize else "auto"}, maxFps=${if (maxFps > 0f) maxFps else "auto"}")
+ scope.launch {
+ snack.showSnackbar("scrcpy 已启动")
+ }
+ nativeCore.getLastScrcpyServerCommand()?.let { command ->
+ logEvent("scrcpy-server args: $command")
+ }
+ }
+ },
+ onStop = {
+ runBusy("停止 scrcpy") {
+ nativeCore.scrcpyStop()
+ sessionInfo = null
+ statusLine =
+ currentTarget?.let { "${it.host}:${it.port}" } ?: "ADB 已连接"
+ logEvent("scrcpy 已停止")
+ scope.launch {
+ snack.showSnackbar("scrcpy 已停止")
+ }
+ }
+ },
+ sessionStarted = sessionInfo != null,
+ )
+ }
+
+ if (
+ sessionInfo != null &&
+ sessionInfo!!.width > 0 &&
+ sessionInfo!!.height > 0
+ ) {
+ item {
+ PreviewCard(
+ sessionInfo = sessionInfo,
+ nativeCore = nativeCore,
+ previewHeightDp = previewCardHeightDp.coerceAtLeast(120),
+ controlsVisible = previewControlsVisible,
+ onTapped = {
+ previewControlsVisible = true
+ scope.launch {
+ delay(2000)
+ previewControlsVisible = false
+ }
+ },
+ onOpenFullscreen = {
+ val info = sessionInfo ?: return@PreviewCard
+ onOpenFullscreenPage(info)
+ },
+ onOpenFullscreenHaptic = { haptics.press() },
+ )
+ }
+ item {
+ VirtualButtonCard(
+ busy = busy,
+ outsideActions = virtualButtonLayout.first,
+ moreActions = virtualButtonLayout.second,
+ onPressHaptic = { haptics.press() },
+ onConfirmHaptic = { haptics.confirm() },
+ onAction = ::sendVirtualButtonAction,
+ )
+ }
+ }
+
+ item {
+ Spacer(Modifier.height(UiSpacing.PageItem))
+ LogsPanel(lines = eventLog)
+ }
+ }
+
+ // TODO: 放进 [AppPageLazyColumn] 里
+ item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
+ }
+}
+
+private fun buildNewDisplayArg(width: String, height: String, dpi: String): String {
+ val w = width.toIntOrNull()?.takeIf { it > 0 }
+ val h = height.toIntOrNull()?.takeIf { it > 0 }
+ val d = dpi.toIntOrNull()?.takeIf { it > 0 }
+ val sizePart = if (w != null && h != null) "${w}x${h}" else ""
+ return when {
+ sizePart.isNotEmpty() && d != null -> "$sizePart/$d"
+ sizePart.isNotEmpty() -> sizePart
+ d != null -> "/$d"
+ else -> ""
+ }
+}
+
+private fun buildCropArg(width: String, height: String, x: String, y: String): String {
+ val w = width.toIntOrNull()?.takeIf { it > 0 } ?: return ""
+ val h = height.toIntOrNull()?.takeIf { it > 0 } ?: return ""
+ val ox = x.toIntOrNull()?.takeIf { it >= 0 } ?: return ""
+ val oy = y.toIntOrNull()?.takeIf { it >= 0 } ?: return ""
+ return "$w:$h:$ox:$oy"
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt
new file mode 100644
index 0000000..defcd35
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlPage.kt
@@ -0,0 +1,163 @@
+package io.github.miuzarte.scrcpyforandroid.pages
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
+import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
+import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
+import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
+import top.yukonga.miuix.kmp.basic.Scaffold
+
+data class FullscreenControlLaunch(
+ val deviceName: String,
+ val width: Int,
+ val height: Int,
+ val codec: String,
+)
+
+@Composable
+fun FullscreenControlPage(
+ launch: FullscreenControlLaunch,
+ nativeCore: NativeCoreFacade,
+ virtualButtonsOutside: List,
+ virtualButtonsInMore: List,
+ showDebugInfo: Boolean,
+ onDismiss: () -> Unit,
+) {
+ val context = LocalContext.current
+ val haptics = rememberAppHaptics()
+ val activity = remember(context) { context as? Activity }
+ val virtualButtonLayout = remember(virtualButtonsOutside, virtualButtonsInMore) {
+ VirtualButtonActions.resolveLayout(virtualButtonsOutside, virtualButtonsInMore)
+ }
+ val bar = remember(virtualButtonLayout) {
+ VirtualButtonBar(
+ outsideActions = virtualButtonLayout.first,
+ moreActions = virtualButtonLayout.second,
+ )
+ }
+ val initialOrientation = remember(activity) { activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
+ var session by remember(launch) {
+ mutableStateOf(
+ ScrcpySessionInfo(
+ width = launch.width,
+ height = launch.height,
+ deviceName = launch.deviceName.ifBlank { "设备" },
+ codec = launch.codec.ifBlank { "unknown" },
+ controlEnabled = true,
+ ),
+ )
+ }
+ var currentFps by remember { mutableFloatStateOf(0f) }
+
+ DisposableEffect(activity, session.width, session.height) {
+ val targetOrientation = if (session.width >= session.height) {
+ ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ } else {
+ ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
+ }
+ activity?.requestedOrientation = targetOrientation
+ onDispose {
+ activity?.requestedOrientation = initialOrientation
+ }
+ }
+
+ DisposableEffect(activity) {
+ val window = activity?.window
+ if (window != null) {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ val controller = WindowInsetsControllerCompat(window, window.decorView)
+ controller.hide(WindowInsetsCompat.Type.systemBars())
+ controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ onDispose {
+ val restoreWindow = activity?.window
+ if (restoreWindow != null) {
+ WindowInsetsControllerCompat(restoreWindow, restoreWindow.decorView).show(WindowInsetsCompat.Type.systemBars())
+ WindowCompat.setDecorFitsSystemWindows(restoreWindow, true)
+ }
+ }
+ }
+
+ DisposableEffect(nativeCore) {
+ val listener: (Int, Int) -> Unit = { w, h ->
+ session = session.copy(width = w, height = h)
+ }
+ nativeCore.addVideoSizeListener(listener)
+ onDispose {
+ nativeCore.removeVideoSizeListener(listener)
+ }
+ }
+
+ DisposableEffect(nativeCore) {
+ val listener: (Float) -> Unit = { fps ->
+ currentFps = fps
+ }
+ nativeCore.addVideoFpsListener(listener)
+ onDispose {
+ nativeCore.removeVideoFpsListener(listener)
+ }
+ }
+
+ fun sendKeycode(keycode: Int) {
+ nativeCore.scrcpyInjectKeycode(0, keycode)
+ nativeCore.scrcpyInjectKeycode(1, keycode)
+ }
+
+ Scaffold(contentWindowInsets = WindowInsets(0, 0, 0, 0)) { contentPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ ) {
+ FullscreenControlScreen(
+ session = session,
+ nativeCore = nativeCore,
+ onDismiss = onDismiss,
+ showDebugInfo = showDebugInfo,
+ currentFps = currentFps,
+ enableBackHandler = false,
+ onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
+ nativeCore.scrcpyInjectTouch(
+ action = action,
+ pointerId = pointerId,
+ x = x,
+ y = y,
+ screenWidth = session.width,
+ screenHeight = session.height,
+ pressure = pressure,
+ buttons = buttons,
+ )
+ },
+ )
+
+ bar.Fullscreen(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ onPressHaptic = { haptics.press() },
+ onConfirmHaptic = { haptics.confirm() },
+ onAction = { action ->
+ action.keycode?.let(::sendKeycode)
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt
new file mode 100644
index 0000000..a46ec25
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt
@@ -0,0 +1,728 @@
+package io.github.miuzarte.scrcpyforandroid.pages
+
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.PredictiveBackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Devices
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberDecoratedNavEntries
+import androidx.navigation3.ui.NavDisplay
+import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
+import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
+import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.services.MainSettings
+import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
+import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
+import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import top.yukonga.miuix.kmp.basic.DropdownImpl
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.IconButton
+import top.yukonga.miuix.kmp.basic.ListPopupColumn
+import top.yukonga.miuix.kmp.basic.ListPopupDefaults
+import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
+import top.yukonga.miuix.kmp.basic.NavigationBar
+import top.yukonga.miuix.kmp.basic.NavigationBarItem
+import top.yukonga.miuix.kmp.basic.PopupPositionProvider
+import top.yukonga.miuix.kmp.basic.Scaffold
+import top.yukonga.miuix.kmp.basic.SnackbarHost
+import top.yukonga.miuix.kmp.basic.SnackbarHostState
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TopAppBar
+import top.yukonga.miuix.kmp.extra.SuperListPopup
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.theme.ThemeController
+
+private enum class MainTabDestination(
+ val title: String,
+ val label: String,
+ val icon: ImageVector,
+) {
+ Device(title = "设备", label = "设备", icon = Icons.Default.Devices),
+ Settings(title = "设置", label = "设置", icon = Icons.Default.Settings),
+}
+
+private sealed interface RootScreen : NavKey {
+ data object Home : RootScreen
+ data object Advanced : RootScreen
+ data object VirtualButtonOrder : RootScreen
+ data class Fullscreen(val launch: FullscreenControlLaunch) : RootScreen
+}
+
+@Composable
+fun MainPage() {
+ val context = LocalContext.current
+ val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
+ val initialSettings = remember(context) { loadMainSettings(context) }
+ val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
+ val snackHostState = remember { SnackbarHostState() }
+ val tabs = remember { MainTabDestination.entries }
+ val pagerState = rememberPagerState(initialPage = MainTabDestination.Device.ordinal, pageCount = { tabs.size })
+ val currentTab = tabs[pagerState.currentPage]
+ val saveableStateHolder = rememberSaveableStateHolder()
+ val scope = rememberCoroutineScope()
+ val rootBackStack = remember { mutableStateListOf(RootScreen.Home) }
+ val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
+ val deviceScrollBehavior = MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Device })
+ val settingsScrollBehavior = MiuixScrollBehavior(canScroll = { currentTab == MainTabDestination.Settings })
+ val advancedScrollBehavior = MiuixScrollBehavior(
+ canScroll = {
+ currentRootScreen is RootScreen.Advanced || currentRootScreen is RootScreen.VirtualButtonOrder
+ },
+ )
+ val stringListSaver = listSaver, String>(
+ save = { value -> ArrayList(value) },
+ restore = { restored -> restored.toList() },
+ )
+
+ var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) }
+ var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) }
+ var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) }
+ var keepScreenOnWhenStreamingEnabled by rememberSaveable {
+ mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled)
+ }
+ var virtualButtonsOutside by rememberSaveable(stateSaver = stringListSaver) {
+ mutableStateOf(VirtualButtonActions.parseStoredIds(initialSettings.virtualButtonsOutside))
+ }
+ var virtualButtonsInMore by rememberSaveable(stateSaver = stringListSaver) {
+ mutableStateOf(VirtualButtonActions.parseStoredIds(initialSettings.virtualButtonsInMore))
+ }
+ var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) }
+ var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) }
+ var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) }
+ var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) }
+ var audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) }
+ var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) }
+ var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) }
+ var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) }
+ var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) }
+ var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) }
+ var audioEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.audioEncoder) }
+ var audioCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.audioCodecOptions) }
+ var audioDup by rememberSaveable { mutableStateOf(initialDeviceSettings.audioDup) }
+ var audioSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourcePreset) }
+ var audioSourceCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourceCustom) }
+ var videoSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.videoSourcePreset) }
+ var cameraIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraIdInput) }
+ var cameraFacingPreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFacingPreset) }
+ var cameraSizePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizePreset) }
+ var cameraSizeCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizeCustom) }
+ var cameraArInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraArInput) }
+ var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFpsInput) }
+ var cameraHighSpeed by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraHighSpeed) }
+ var noAudioPlayback by rememberSaveable { mutableStateOf(initialDeviceSettings.noAudioPlayback) }
+ var noVideo by rememberSaveable { mutableStateOf(initialDeviceSettings.noVideo) }
+ var requireAudio by rememberSaveable { mutableStateOf(initialDeviceSettings.requireAudio) }
+ var turnScreenOff by rememberSaveable { mutableStateOf(initialDeviceSettings.turnScreenOff) }
+ var maxSizeInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxSizeInput) }
+ var maxFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxFpsInput) }
+ var newDisplayWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayWidth) }
+ var newDisplayHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayHeight) }
+ var newDisplayDpi by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayDpi) }
+ var displayIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.displayIdInput) }
+ var cropWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.cropWidth) }
+ var cropHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.cropHeight) }
+ var cropX by rememberSaveable { mutableStateOf(initialDeviceSettings.cropX) }
+ var cropY by rememberSaveable { mutableStateOf(initialDeviceSettings.cropY) }
+ val videoEncoderOptions = remember { mutableStateListOf() }
+ val audioEncoderOptions = remember { mutableStateListOf() }
+ val videoEncoderTypeMap = remember { mutableStateMapOf() }
+ val audioEncoderTypeMap = remember { mutableStateMapOf() }
+ val cameraSizeOptions = remember { mutableStateListOf() }
+ var sessionStarted by remember { mutableStateOf(false) }
+ var refreshEncodersAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+ var refreshCameraSizesAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+ var clearLogsAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+ var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+ var canClearLogs by remember { mutableStateOf(false) }
+ var showDeviceMenu by rememberSaveable { mutableStateOf(false) }
+ val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled)
+ val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
+
+ LaunchedEffect(
+ themeBaseIndex,
+ monetEnabled,
+ fullscreenDebugInfoEnabled,
+ keepScreenOnWhenStreamingEnabled,
+ virtualButtonsOutside,
+ virtualButtonsInMore,
+ devicePreviewCardHeightDp,
+ customServerUri,
+ serverRemotePath,
+ videoCodec,
+ audioEnabled,
+ audioCodec,
+ adbKeyName,
+ ) {
+ saveMainSettings(
+ context,
+ MainSettings(
+ themeBaseIndex = themeBaseIndex,
+ monetEnabled = monetEnabled,
+ fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
+ keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
+ virtualButtonsOutside = VirtualButtonActions.encodeStoredIds(virtualButtonsOutside),
+ virtualButtonsInMore = VirtualButtonActions.encodeStoredIds(virtualButtonsInMore),
+ devicePreviewCardHeightDp = devicePreviewCardHeightDp,
+ customServerUri = customServerUri,
+ serverRemotePath = serverRemotePath,
+ videoCodec = videoCodec,
+ audioEnabled = audioEnabled,
+ audioCodec = audioCodec,
+ adbKeyName = adbKeyName,
+ ),
+ )
+ }
+
+ LaunchedEffect(adbKeyName) {
+ nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.DefaultAdbKeyName })
+ }
+
+ val canNavigateBack = rootBackStack.size > 1 || pagerState.currentPage != MainTabDestination.Device.ordinal
+
+ fun popRoot() {
+ if (rootBackStack.size > 1) {
+ rootBackStack.removeAt(rootBackStack.lastIndex)
+ }
+ }
+
+ fun handleBackNavigation() {
+ if (rootBackStack.size > 1) {
+ popRoot()
+ } else if (pagerState.currentPage != MainTabDestination.Device.ordinal) {
+ scope.launch {
+ pagerState.animateScrollToPage(
+ page = MainTabDestination.Device.ordinal,
+ animationSpec = spring(
+ dampingRatio = UiMotion.PageSwitchDampingRatio,
+ stiffness = UiMotion.PageSwitchStiffness,
+ ),
+ )
+ }
+ }
+ }
+
+ PredictiveBackHandler(enabled = canNavigateBack) { progress ->
+ try {
+ progress.collect { }
+ handleBackNavigation()
+ } catch (_: CancellationException) {
+ // Gesture was cancelled by the system/user.
+ }
+ }
+
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ runCatching {
+ context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ customServerUri = uri.toString()
+ }
+
+ val rootEntryProvider = entryProvider {
+ entry(RootScreen.Home) {
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ tabs.forEach { tab ->
+ NavigationBarItem(
+ selected = currentTab == tab,
+ onClick = {
+ scope.launch {
+ pagerState.animateScrollToPage(
+ page = tab.ordinal,
+ animationSpec = spring(
+ dampingRatio = UiMotion.PageSwitchDampingRatio,
+ stiffness = UiMotion.PageSwitchStiffness,
+ ),
+ )
+ }
+ },
+ icon = tab.icon,
+ label = tab.label,
+ )
+ }
+ }
+ },
+ snackbarHost = { SnackbarHost(snackHostState) },
+ ) { contentPadding ->
+ HorizontalPager(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = contentPadding.calculateBottomPadding()),
+ state = pagerState,
+ beyondViewportPageCount = 1,
+ ) { page ->
+ val tab = tabs[page]
+ saveableStateHolder.SaveableStateProvider(tab.name) {
+ when (tab) {
+ MainTabDestination.Device -> Scaffold(
+ topBar = {
+ TopAppBar(
+ title = tab.title,
+ actions = {
+ IconButton(
+ onClick = { showDeviceMenu = true },
+ holdDownState = showDeviceMenu,
+ ) {
+ Icon(Icons.Default.MoreVert, contentDescription = "更多")
+ }
+ DeviceMenuPopup(
+ show = showDeviceMenu,
+ canClearLogs = canClearLogs,
+ onDismissRequest = { showDeviceMenu = false },
+ onReorderDevices = {
+ openReorderDevicesAction?.invoke()
+ showDeviceMenu = false
+ },
+ onOpenVirtualButtonOrder = {
+ rootBackStack.add(RootScreen.VirtualButtonOrder)
+ showDeviceMenu = false
+ },
+ onClearLogs = {
+ clearLogsAction?.invoke()
+ showDeviceMenu = false
+ },
+ )
+ },
+ scrollBehavior = deviceScrollBehavior,
+ )
+ },
+ ) { pagePadding ->
+ DeviceTabScreen(
+ contentPadding = pagePadding,
+ nativeCore = nativeCore,
+ snack = snackHostState,
+ scrollBehavior = deviceScrollBehavior,
+ keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
+ virtualButtonsOutside = virtualButtonsOutside,
+ virtualButtonsInMore = virtualButtonsInMore,
+ customServerUri = customServerUri,
+ serverRemotePath = serverRemotePath,
+ onServerRemotePathChange = { serverRemotePath = it },
+ videoCodec = videoCodec,
+ onVideoCodecChange = { videoCodec = it },
+ audioEnabled = audioEnabled,
+ onAudioEnabledChange = { audioEnabled = it },
+ audioCodec = audioCodec,
+ onAudioCodecChange = { audioCodec = it },
+ noControl = noControl,
+ onNoControlChange = {
+ noControl = it
+ if (it) {
+ turnScreenOff = false
+ }
+ },
+ videoEncoder = videoEncoder,
+ onVideoEncoderChange = { videoEncoder = it },
+ videoCodecOptions = videoCodecOptions,
+ onVideoCodecOptionsChange = { videoCodecOptions = it },
+ audioEncoder = audioEncoder,
+ onAudioEncoderChange = { audioEncoder = it },
+ audioCodecOptions = audioCodecOptions,
+ onAudioCodecOptionsChange = { audioCodecOptions = it },
+ audioDup = audioDup,
+ onAudioDupChange = { audioDup = it },
+ audioSourcePreset = audioSourcePreset,
+ onAudioSourcePresetChange = { audioSourcePreset = it },
+ audioSourceCustom = audioSourceCustom,
+ onAudioSourceCustomChange = { audioSourceCustom = it },
+ videoSourcePreset = videoSourcePreset,
+ onVideoSourcePresetChange = { videoSourcePreset = it },
+ cameraIdInput = cameraIdInput,
+ onCameraIdInputChange = { cameraIdInput = it },
+ cameraFacingPreset = cameraFacingPreset,
+ onCameraFacingPresetChange = { cameraFacingPreset = it },
+ cameraSizePreset = cameraSizePreset,
+ onCameraSizePresetChange = { cameraSizePreset = it },
+ cameraSizeCustom = cameraSizeCustom,
+ onCameraSizeCustomChange = { cameraSizeCustom = it },
+ cameraArInput = cameraArInput,
+ onCameraArInputChange = { cameraArInput = it },
+ cameraFpsInput = cameraFpsInput,
+ onCameraFpsInputChange = { cameraFpsInput = it },
+ cameraHighSpeed = cameraHighSpeed,
+ onCameraHighSpeedChange = { cameraHighSpeed = it },
+ noAudioPlayback = noAudioPlayback,
+ onNoAudioPlaybackChange = { noAudioPlayback = it },
+ noVideo = noVideo,
+ requireAudio = requireAudio,
+ onRequireAudioChange = { requireAudio = it },
+ turnScreenOff = turnScreenOff,
+ onTurnScreenOffChange = { turnScreenOff = it },
+ maxSizeInput = maxSizeInput,
+ onMaxSizeInputChange = { maxSizeInput = it },
+ maxFpsInput = maxFpsInput,
+ onMaxFpsInputChange = { maxFpsInput = it },
+ newDisplayWidth = newDisplayWidth,
+ onNewDisplayWidthChange = { newDisplayWidth = it },
+ newDisplayHeight = newDisplayHeight,
+ onNewDisplayHeightChange = { newDisplayHeight = it },
+ newDisplayDpi = newDisplayDpi,
+ onNewDisplayDpiChange = { newDisplayDpi = it },
+ displayIdInput = displayIdInput,
+ onDisplayIdInputChange = { displayIdInput = it },
+ cropWidth = cropWidth,
+ onCropWidthChange = { cropWidth = it },
+ cropHeight = cropHeight,
+ onCropHeightChange = { cropHeight = it },
+ cropX = cropX,
+ onCropXChange = { cropX = it },
+ cropY = cropY,
+ onCropYChange = { cropY = it },
+ videoEncoderOptions = videoEncoderOptions,
+ onVideoEncoderOptionsChange = {
+ videoEncoderOptions.clear()
+ videoEncoderOptions.addAll(it)
+ },
+ onVideoEncoderTypeMapChange = {
+ videoEncoderTypeMap.clear()
+ videoEncoderTypeMap.putAll(it)
+ },
+ audioEncoderOptions = audioEncoderOptions,
+ onAudioEncoderOptionsChange = {
+ audioEncoderOptions.clear()
+ audioEncoderOptions.addAll(it)
+ },
+ onAudioEncoderTypeMapChange = {
+ audioEncoderTypeMap.clear()
+ audioEncoderTypeMap.putAll(it)
+ },
+ cameraSizeOptions = cameraSizeOptions,
+ onCameraSizeOptionsChange = {
+ cameraSizeOptions.clear()
+ cameraSizeOptions.addAll(it)
+ },
+ onSessionStartedChange = { sessionStarted = it },
+ onRefreshEncodersActionChange = { refreshEncodersAction = it },
+ onRefreshCameraSizesActionChange = { refreshCameraSizesAction = it },
+ onClearLogsActionChange = { clearLogsAction = it },
+ onCanClearLogsChange = { canClearLogs = it },
+ onOpenReorderDevicesActionChange = { openReorderDevicesAction = it },
+ onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) },
+ onOpenFullscreenPage = { session ->
+ rootBackStack.add(
+ RootScreen.Fullscreen(
+ launch = FullscreenControlLaunch(
+ deviceName = session.deviceName,
+ width = session.width,
+ height = session.height,
+ codec = session.codec,
+ ),
+ ),
+ )
+ },
+ previewCardHeightDp = devicePreviewCardHeightDp,
+ )
+ }
+
+ MainTabDestination.Settings -> Scaffold(
+ topBar = {
+ TopAppBar(
+ title = tab.title,
+ scrollBehavior = settingsScrollBehavior,
+ )
+ },
+ ) { pagePadding ->
+ SettingsScreen(
+ contentPadding = pagePadding,
+ themeBaseIndex = themeBaseIndex,
+ onThemeBaseIndexChange = { themeBaseIndex = it },
+ monetEnabled = monetEnabled,
+ onMonetEnabledChange = { monetEnabled = it },
+ fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
+ onFullscreenDebugInfoEnabledChange = { fullscreenDebugInfoEnabled = it },
+ keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
+ onKeepScreenOnWhenStreamingEnabledChange = {
+ keepScreenOnWhenStreamingEnabled = it
+ },
+ devicePreviewCardHeightDp = devicePreviewCardHeightDp,
+ onDevicePreviewCardHeightDpChange = {
+ devicePreviewCardHeightDp = it.coerceAtLeast(120)
+ },
+ customServerUri = customServerUri,
+ onPickServer = {
+ picker.launch(arrayOf("application/java-archive", "application/octet-stream", "*/*"))
+ },
+ onClearServer = { customServerUri = null },
+ serverRemotePath = serverRemotePath,
+ onServerRemotePathChange = { serverRemotePath = it },
+ adbKeyName = adbKeyName,
+ onAdbKeyNameChange = { adbKeyName = it },
+ scrollBehavior = settingsScrollBehavior,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ entry(RootScreen.Advanced) {
+ val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions
+ val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions
+ val videoEncoderIndex = (videoEncoderOptions.indexOf(videoEncoder) + 1).coerceAtLeast(0)
+ val audioEncoderIndex = (audioEncoderOptions.indexOf(audioEncoder) + 1).coerceAtLeast(0)
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = "高级参数",
+ navigationIcon = {
+ IconButton(onClick = { popRoot() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
+ }
+ },
+ scrollBehavior = advancedScrollBehavior,
+ )
+ },
+ snackbarHost = { SnackbarHost(snackHostState) },
+ ) { pagePadding ->
+ AdvancedConfigPage(
+ contentPadding = pagePadding,
+ scrollBehavior = advancedScrollBehavior,
+ sessionStarted = sessionStarted,
+ snackbarHostState = snackHostState,
+ audioEnabled = audioEnabled,
+ noControl = noControl,
+ onNoControlChange = {
+ noControl = it
+ if (it) {
+ turnScreenOff = false
+ }
+ },
+ audioDup = audioDup,
+ onAudioDupChange = { audioDup = it },
+ audioSourcePreset = audioSourcePreset,
+ onAudioSourcePresetChange = { audioSourcePreset = it },
+ audioSourceCustom = audioSourceCustom,
+ onAudioSourceCustomChange = { audioSourceCustom = it },
+ videoSourcePreset = videoSourcePreset,
+ onVideoSourcePresetChange = { videoSourcePreset = it },
+ cameraIdInput = cameraIdInput,
+ onCameraIdInputChange = { cameraIdInput = it },
+ cameraFacingPreset = cameraFacingPreset,
+ onCameraFacingPresetChange = { cameraFacingPreset = it },
+ cameraSizePreset = cameraSizePreset,
+ onCameraSizePresetChange = { cameraSizePreset = it },
+ cameraSizeCustom = cameraSizeCustom,
+ onCameraSizeCustomChange = { cameraSizeCustom = it },
+ cameraSizeDropdownItems = listOf("默认") + cameraSizeOptions + listOf("自定义"),
+ cameraSizeIndex = when (cameraSizePreset) {
+ "custom" -> cameraSizeOptions.size + 1
+ in cameraSizeOptions -> cameraSizeOptions.indexOf(cameraSizePreset) + 1
+ else -> 0
+ },
+ cameraArInput = cameraArInput,
+ onCameraArInputChange = { cameraArInput = it },
+ cameraFpsInput = cameraFpsInput,
+ onCameraFpsInputChange = { cameraFpsInput = it },
+ cameraHighSpeed = cameraHighSpeed,
+ onCameraHighSpeedChange = { cameraHighSpeed = it },
+ noAudioPlayback = noAudioPlayback,
+ onNoAudioPlaybackChange = { noAudioPlayback = it },
+ noVideo = noVideo,
+ onNoVideoChange = { noVideo = it },
+ requireAudio = requireAudio,
+ onRequireAudioChange = { requireAudio = it },
+ turnScreenOff = turnScreenOff,
+ onTurnScreenOffChange = { turnScreenOff = it },
+ maxSizeInput = maxSizeInput,
+ onMaxSizeInputChange = { maxSizeInput = it },
+ maxFpsInput = maxFpsInput,
+ onMaxFpsInputChange = { maxFpsInput = it },
+ videoEncoderDropdownItems = videoEncoderDropdownItems,
+ videoEncoderTypeMap = videoEncoderTypeMap,
+ videoEncoderIndex = videoEncoderIndex,
+ onVideoEncoderChange = { videoEncoder = it },
+ videoCodecOptions = videoCodecOptions,
+ onVideoCodecOptionsChange = { videoCodecOptions = it },
+ audioEncoderDropdownItems = audioEncoderDropdownItems,
+ audioEncoderTypeMap = audioEncoderTypeMap,
+ audioEncoderIndex = audioEncoderIndex,
+ onAudioEncoderChange = { audioEncoder = it },
+ audioCodecOptions = audioCodecOptions,
+ onAudioCodecOptionsChange = { audioCodecOptions = it },
+ onRefreshEncoders = { refreshEncodersAction?.invoke() },
+ onRefreshCameraSizes = { refreshCameraSizesAction?.invoke() },
+ newDisplayWidth = newDisplayWidth,
+ onNewDisplayWidthChange = { newDisplayWidth = it },
+ newDisplayHeight = newDisplayHeight,
+ onNewDisplayHeightChange = { newDisplayHeight = it },
+ newDisplayDpi = newDisplayDpi,
+ onNewDisplayDpiChange = { newDisplayDpi = it },
+ displayIdInput = displayIdInput,
+ onDisplayIdInputChange = { displayIdInput = it },
+ cropWidth = cropWidth,
+ onCropWidthChange = { cropWidth = it },
+ cropHeight = cropHeight,
+ onCropHeightChange = { cropHeight = it },
+ cropX = cropX,
+ onCropXChange = { cropX = it },
+ cropY = cropY,
+ onCropYChange = { cropY = it },
+ )
+ }
+ }
+
+ entry(RootScreen.VirtualButtonOrder) {
+ Scaffold(
+ modifier = Modifier.nestedScroll(advancedScrollBehavior.nestedScrollConnection),
+ topBar = {
+ TopAppBar(
+ title = "虚拟按钮排序",
+ navigationIcon = {
+ IconButton(onClick = { popRoot() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
+ }
+ },
+ scrollBehavior = advancedScrollBehavior,
+ )
+ },
+ ) { pagePadding ->
+ VirtualButtonOrderPage(
+ contentPadding = pagePadding,
+ scrollBehavior = advancedScrollBehavior,
+ outsideIds = virtualButtonsOutside,
+ moreIds = virtualButtonsInMore,
+ onLayoutChange = { outside, more ->
+ virtualButtonsOutside = outside
+ virtualButtonsInMore = more
+ },
+ )
+ }
+ }
+
+ entry { screen ->
+ FullscreenControlPage(
+ launch = screen.launch,
+ nativeCore = nativeCore,
+ virtualButtonsOutside = virtualButtonsOutside,
+ virtualButtonsInMore = virtualButtonsInMore,
+ showDebugInfo = fullscreenDebugInfoEnabled,
+ onDismiss = { popRoot() },
+ )
+ }
+ }
+
+ val rootEntries = rememberDecoratedNavEntries(
+ backStack = rootBackStack,
+ entryProvider = rootEntryProvider,
+ )
+
+ MiuixTheme(controller = themeController) {
+ NavDisplay(
+ entries = rootEntries,
+ onBack = { popRoot() },
+ )
+ }
+}
+
+@Composable
+private fun DeviceMenuPopup(
+ show: Boolean,
+ canClearLogs: Boolean,
+ onDismissRequest: () -> Unit,
+ onReorderDevices: () -> Unit,
+ onOpenVirtualButtonOrder: () -> Unit,
+ onClearLogs: () -> Unit,
+) {
+ SuperListPopup(
+ show = show,
+ popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
+ alignment = PopupPositionProvider.Align.TopEnd,
+ onDismissRequest = onDismissRequest,
+ enableWindowDim = false,
+ ) {
+ ListPopupColumn {
+ DeviceMenuPopupItem(
+ text = "调整设备排序",
+ optionSize = 3,
+ index = 0,
+ onClick = onReorderDevices,
+ )
+ DeviceMenuPopupItem(
+ text = "虚拟按钮排序",
+ optionSize = 3,
+ index = 1,
+ onClick = onOpenVirtualButtonOrder,
+ )
+ DeviceMenuPopupItem(
+ text = "清空日志",
+ optionSize = 3,
+ index = 2,
+ enabled = canClearLogs,
+ onClick = onClearLogs,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DeviceMenuPopupItem(
+ text: String,
+ optionSize: Int,
+ index: Int,
+ enabled: Boolean = true,
+ // TODO: (Int) -> Unit
+ onClick: () -> Unit,
+) {
+ if (enabled) {
+ DropdownImpl(
+ text = text,
+ optionSize = optionSize,
+ isSelected = false,
+ index = index,
+ onSelectedIndexChange = { onClick() },
+ )
+ return
+ }
+
+ val additionalTopPadding = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem
+ val additionalBottomPadding = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem
+ Text(
+ text = text,
+ fontSize = MiuixTheme.textStyles.body1.fontSize,
+ fontWeight = FontWeight.Medium,
+ color = MiuixTheme.colorScheme.disabledOnSecondaryVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.PopupHorizontal)
+ .padding(top = additionalTopPadding, bottom = additionalBottomPadding),
+ )
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt
new file mode 100644
index 0000000..d4d1980
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt
@@ -0,0 +1,207 @@
+package io.github.miuzarte.scrcpyforandroid.pages
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
+import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
+import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.IconButton
+import top.yukonga.miuix.kmp.basic.ScrollBehavior
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextField
+import top.yukonga.miuix.kmp.extra.SuperDropdown
+import top.yukonga.miuix.kmp.extra.SuperSwitch
+import top.yukonga.miuix.kmp.theme.ColorSchemeMode
+import kotlin.math.roundToInt
+
+private data class ThemeModeOption(
+ val label: String,
+ val mode: ColorSchemeMode,
+)
+
+private val THEME_BASE_OPTIONS = listOf(
+ ThemeModeOption("跟随系统", ColorSchemeMode.System),
+ ThemeModeOption("浅色", ColorSchemeMode.Light),
+ ThemeModeOption("深色", ColorSchemeMode.Dark),
+)
+
+fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode {
+ return when (baseIndex.coerceIn(0, 2)) {
+ 0 -> if (monetEnabled) ColorSchemeMode.MonetSystem else ColorSchemeMode.System
+ 1 -> if (monetEnabled) ColorSchemeMode.MonetLight else ColorSchemeMode.Light
+ else -> if (monetEnabled) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark
+ }
+}
+
+private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
+ val base = THEME_BASE_OPTIONS.getOrNull(baseIndex.coerceIn(0, 2))?.label ?: "跟随系统"
+ return if (monetEnabled) "Monet($base)" else base
+}
+
+@Composable
+fun SettingsScreen(
+ contentPadding: PaddingValues,
+ themeBaseIndex: Int,
+ onThemeBaseIndexChange: (Int) -> Unit,
+ monetEnabled: Boolean,
+ onMonetEnabledChange: (Boolean) -> Unit,
+ fullscreenDebugInfoEnabled: Boolean,
+ onFullscreenDebugInfoEnabledChange: (Boolean) -> Unit,
+ keepScreenOnWhenStreamingEnabled: Boolean,
+ onKeepScreenOnWhenStreamingEnabledChange: (Boolean) -> Unit,
+ devicePreviewCardHeightDp: Int,
+ onDevicePreviewCardHeightDpChange: (Int) -> Unit,
+ customServerUri: String?,
+ onPickServer: () -> Unit,
+ onClearServer: () -> Unit,
+ serverRemotePath: String,
+ onServerRemotePathChange: (String) -> Unit,
+ adbKeyName: String,
+ onAdbKeyNameChange: (String) -> Unit,
+ scrollBehavior: ScrollBehavior,
+) {
+ val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
+
+ // 设置
+ AppPageLazyColumn(
+ contentPadding = contentPadding,
+ scrollBehavior = scrollBehavior,
+ ) {
+ item {
+ SectionSmallTitle(text = "主题")
+ Card {
+ SuperDropdown(
+ title = "外观模式",
+ summary = resolveThemeLabel(themeBaseIndex, monetEnabled),
+ items = baseModeItems,
+ selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
+ onSelectedIndexChange = onThemeBaseIndexChange,
+ )
+ SuperSwitch(
+ title = "Monet",
+ summary = "开启后使用 Monet 动态配色",
+ checked = monetEnabled,
+ onCheckedChange = onMonetEnabledChange,
+ )
+ }
+
+ SectionSmallTitle(text = "投屏")
+ Card {
+ SuperSwitch(
+ title = "启用调试信息",
+ summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
+ checked = fullscreenDebugInfoEnabled,
+ onCheckedChange = onFullscreenDebugInfoEnabledChange,
+ )
+ SuperSwitch(
+ title = "投屏时不允许息屏",
+ summary = "Scrcpy 启动后保持本机常亮,避免锁屏导致 ADB 断开",
+ checked = keepScreenOnWhenStreamingEnabled,
+ onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange,
+ )
+ SuperSlide(
+ title = "预览卡高度",
+ summary = "设备页预览卡高度",
+ value = devicePreviewCardHeightDp.toFloat(),
+ onValueChange = { onDevicePreviewCardHeightDpChange(it.roundToInt().coerceAtLeast(120)) },
+ valueRange = 160f..600f,
+ steps = 439,
+ unit = "dp",
+ displayFormatter = { it.roundToInt().toString() },
+ inputInitialValue = devicePreviewCardHeightDp.toString(),
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 120f..Float.MAX_VALUE,
+ onInputConfirm = { raw ->
+ raw.toIntOrNull()?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) }
+ },
+ )
+ }
+
+ SectionSmallTitle(text = "scrcpy-server")
+ Card {
+ Spacer(modifier = Modifier.padding(top = UiSpacing.CardContent))
+ Text(
+ text = "自定义 binary",
+ modifier = Modifier
+ .padding(horizontal = UiSpacing.CardTitle)
+ .padding(bottom = UiSpacing.FieldLabelBottom),
+ fontWeight = FontWeight.Medium,
+ )
+ TextField(
+ value = customServerUri ?: "",
+ onValueChange = {},
+ readOnly = true,
+ label = "scrcpy-server-v3.3.4",
+ useLabelAsPlaceholder = customServerUri == null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ trailingIcon = {
+ Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
+ if (customServerUri != null) IconButton(onClick = onClearServer) {
+ Icon(Icons.Default.Clear, contentDescription = "清空")
+ }
+ IconButton(onClick = onPickServer) {
+ Icon(Icons.Default.FolderOpen, contentDescription = "选择文件")
+ }
+ }
+ },
+ )
+ Text(
+ text = "Remote Path",
+ modifier = Modifier
+ .padding(horizontal = UiSpacing.CardTitle)
+ .padding(bottom = UiSpacing.FieldLabelBottom),
+ fontWeight = FontWeight.Medium,
+ )
+ TextField(
+ value = serverRemotePath,
+ onValueChange = onServerRemotePathChange,
+ label = AppDefaults.DefaultServerRemotePath,
+ useLabelAsPlaceholder = true,
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ }
+
+ SectionSmallTitle(text = "ADB")
+ Card {
+ Text(
+ text = "自定义 ADB 密钥名",
+ modifier = Modifier
+ .padding(horizontal = UiSpacing.CardTitle)
+ .padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom),
+ fontWeight = FontWeight.Medium,
+ )
+ TextField(
+ value = adbKeyName,
+ onValueChange = onAdbKeyNameChange,
+ label = AppDefaults.DefaultAdbKeyName,
+ useLabelAsPlaceholder = true,
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt
new file mode 100644
index 0000000..39da73e
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderPage.kt
@@ -0,0 +1,164 @@
+package io.github.miuzarte.scrcpyforandroid.pages
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.toMutableStateList
+import androidx.compose.ui.Modifier
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
+import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
+import io.github.miuzarte.scrcpyforandroid.widgets.SortDropPayload
+import io.github.miuzarte.scrcpyforandroid.widgets.SortTransferDirection
+import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardItem
+import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardList
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
+import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.ScrollBehavior
+import top.yukonga.miuix.kmp.basic.Text
+import kotlin.math.roundToInt
+
+private const val LIST_REORDER_STEP_PX = 54f
+private const val LIST_TRANSFER_STEP_PX = 72f
+
+@Composable
+internal fun VirtualButtonOrderPage(
+ contentPadding: PaddingValues,
+ scrollBehavior: ScrollBehavior,
+ outsideIds: List,
+ moreIds: List,
+ onLayoutChange: (outside: List, more: List) -> Unit,
+) {
+ val haptics = rememberAppHaptics()
+ val normalized = remember(outsideIds, moreIds) {
+ VirtualButtonActions.resolveLayout(outsideIds, moreIds)
+ }
+ val outsideState = remember { normalized.first.map { it.id }.toMutableStateList() }
+ val moreState = remember { normalized.second.map { it.id }.toMutableStateList() }
+
+ LaunchedEffect(outsideIds, moreIds) {
+ val resolved = VirtualButtonActions.resolveLayout(outsideIds, moreIds)
+ outsideState.clear()
+ outsideState.addAll(resolved.first.map { it.id })
+ moreState.clear()
+ moreState.addAll(resolved.second.map { it.id })
+ }
+
+ fun emit() {
+ onLayoutChange(outsideState.toList(), moreState.toList())
+ }
+
+ fun reorderInside(list: androidx.compose.runtime.snapshots.SnapshotStateList, itemId: String, deltaY: Float) {
+ val fromIndex = list.indexOf(itemId)
+ if (fromIndex < 0) return
+
+ val steps = (deltaY / LIST_REORDER_STEP_PX).roundToInt()
+ if (steps == 0) return
+
+ val toIndex = (fromIndex + steps).coerceIn(0, list.lastIndex)
+ if (toIndex == fromIndex) return
+
+ val moved = list.removeAt(fromIndex)
+ list.add(toIndex, moved)
+ emit()
+ }
+
+ fun transferToOther(
+ from: androidx.compose.runtime.snapshots.SnapshotStateList,
+ to: androidx.compose.runtime.snapshots.SnapshotStateList,
+ itemId: String,
+ deltaY: Float,
+ ) {
+ val fromIndex = from.indexOf(itemId)
+ if (fromIndex < 0) return
+
+ val steps = (deltaY / LIST_REORDER_STEP_PX).roundToInt()
+ val baseIndex = fromIndex + steps
+ val insertIndex = baseIndex.coerceIn(0, to.size)
+
+ from.removeAt(fromIndex)
+ to.add(insertIndex, itemId)
+ emit()
+ }
+
+ fun handleOutsideDrop(payload: SortDropPayload) {
+ val transfer = payload.transferDirection == SortTransferDirection.TO_RIGHT && payload.deltaX >= LIST_TRANSFER_STEP_PX
+ if (transfer) {
+ if (payload.itemId == VirtualButtonAction.MORE.id) return
+ transferToOther(outsideState, moreState, payload.itemId, payload.deltaY)
+ } else {
+ reorderInside(outsideState, payload.itemId, payload.deltaY)
+ }
+ }
+
+ fun handleMoreDrop(payload: SortDropPayload) {
+ val transfer = payload.transferDirection == SortTransferDirection.TO_LEFT && payload.deltaX <= -LIST_TRANSFER_STEP_PX
+ if (transfer) {
+ transferToOther(moreState, outsideState, payload.itemId, payload.deltaY)
+ } else {
+ reorderInside(moreState, payload.itemId, payload.deltaY)
+ }
+ }
+
+ AppPageLazyColumn(
+ contentPadding = contentPadding,
+ scrollBehavior = scrollBehavior,
+ ) {
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "长按可在两列间拖动排序\n“更多”不可移入右侧菜单",
+ modifier = Modifier.padding(UiSpacing.CardContent),
+ )
+ }
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SortableCardList(
+ title = "外部按钮",
+ items = outsideState.map { id ->
+ val action = VirtualButtonAction.entries.first { it.id == id }
+ SortableCardItem(
+ id = action.id,
+ title = action.title,
+ subtitle = "显示在预览与全屏底部",
+ )
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = UiSpacing.Small),
+ transferDirection = SortTransferDirection.TO_RIGHT,
+ onLongPressHaptic = haptics.press,
+ onDrop = ::handleOutsideDrop,
+ )
+
+ SortableCardList(
+ title = "更多菜单",
+ items = moreState.map { id ->
+ val action = VirtualButtonAction.entries.first { it.id == id }
+ SortableCardItem(
+ id = action.id,
+ title = action.title,
+ subtitle = "显示在“更多”弹窗中",
+ )
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = UiSpacing.Small),
+ transferDirection = SortTransferDirection.TO_LEFT,
+ onLongPressHaptic = haptics.press,
+ onDrop = ::handleMoreDrop,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt
new file mode 100644
index 0000000..bd863f9
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/PageLayouts.kt
@@ -0,0 +1,51 @@
+package io.github.miuzarte.scrcpyforandroid.scaffolds
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.unit.Dp
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import top.yukonga.miuix.kmp.basic.ScrollBehavior
+import top.yukonga.miuix.kmp.utils.overScrollVertical
+
+@Composable
+fun AppPageLazyColumn(
+ contentPadding: PaddingValues,
+ scrollBehavior: ScrollBehavior,
+ modifier: Modifier = Modifier,
+ itemSpacing: Dp = UiSpacing.PageItem,
+ horizontalPadding: Dp = UiSpacing.PageHorizontal,
+ verticalPadding: Dp = UiSpacing.PageVertical,
+ clearFocusOnTap: Boolean = true,
+ content: LazyListScope.() -> Unit,
+) {
+ val focusManager = LocalFocusManager.current
+ val focusClearModifier = if (clearFocusOnTap) {
+ Modifier.pointerInput(Unit) {
+ detectTapGestures(onTap = { focusManager.clearFocus() })
+ }
+ } else {
+ Modifier
+ }
+
+ LazyColumn(
+ modifier = modifier
+ .fillMaxSize()
+ .then(focusClearModifier)
+ .overScrollVertical()
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ .padding(contentPadding),
+ contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = verticalPadding),
+ verticalArrangement = Arrangement.spacedBy(itemSpacing),
+ content = content,
+ )
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt
new file mode 100644
index 0000000..e0e6c80
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlide.kt
@@ -0,0 +1,140 @@
+package io.github.miuzarte.scrcpyforandroid.scaffolds
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import top.yukonga.miuix.kmp.basic.ButtonDefaults
+import top.yukonga.miuix.kmp.basic.Slider
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextButton
+import top.yukonga.miuix.kmp.basic.TextField
+import top.yukonga.miuix.kmp.extra.SuperArrow
+import top.yukonga.miuix.kmp.extra.SuperDialog
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+@Composable
+fun SuperSlide(
+ title: String,
+ summary: String,
+ value: Float,
+ onValueChange: (Float) -> Unit,
+ valueRange: ClosedFloatingPointRange,
+ steps: Int,
+ enabled: Boolean = true,
+ unit: String = "",
+ zeroStateText: String? = null,
+ showUnitWhenZeroState: Boolean = false,
+ showKeyPoints: Boolean = false,
+ keyPoints: List = emptyList(),
+ displayFormatter: (Float) -> String = { it.toInt().toString() },
+ displayText: String? = null,
+ inputTitle: String = title,
+ inputHint: String = unit,
+ inputInitialValue: String = displayFormatter(value),
+ inputFilter: (String) -> String = { text -> text.filter { it.isDigit() || it == '.' } },
+ inputValueRange: ClosedFloatingPointRange? = null,
+ onInputConfirm: (String) -> Unit,
+) {
+ var showInputDialog by remember { mutableStateOf(false) }
+ var holdArrow by remember { mutableStateOf(false) }
+
+ SuperArrow(
+ title = title,
+ summary = summary,
+ onClick = {
+ showInputDialog = true
+ holdArrow = true
+ },
+ holdDownState = holdArrow,
+ endActions = {
+ val isZeroState = value == 0f && zeroStateText != null
+ val valueText = if (isZeroState) zeroStateText else (displayText ?: displayFormatter(value))
+ val shouldShowUnit = unit.isNotBlank() && (!isZeroState || showUnitWhenZeroState)
+ val text = if (shouldShowUnit) "$valueText $unit" else valueText
+ Text(
+ text = text,
+ fontSize = MiuixTheme.textStyles.body2.fontSize,
+ color = MiuixTheme.colorScheme.onSurfaceVariantActions,
+ )
+ },
+ enabled = enabled,
+ bottomAction = {
+ Slider(
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = valueRange,
+ steps = steps,
+ showKeyPoints = showKeyPoints,
+ keyPoints = keyPoints,
+ enabled = enabled,
+ )
+ },
+ )
+
+ if (showInputDialog) {
+ var valueText by remember(inputInitialValue) { mutableStateOf(inputInitialValue) }
+ val activeInputRange = inputValueRange ?: valueRange
+ SuperDialog(
+ show = true,
+ onDismissRequest = {
+ showInputDialog = false
+ holdArrow = false
+ },
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Text(text = inputTitle)
+ }
+ TextField(
+ value = valueText,
+ onValueChange = { valueText = inputFilter(it) },
+ label = inputHint,
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = UiSpacing.Large),
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = UiSpacing.Large),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ TextButton(
+ text = "取消",
+ modifier = Modifier.weight(1f),
+ onClick = {
+ showInputDialog = false
+ holdArrow = false
+ },
+ )
+ TextButton(
+ text = "确定",
+ modifier = Modifier.weight(1f),
+ onClick = {
+ val inputValue = valueText.trim().toFloatOrNull()
+ if (inputValue != null && inputValue >= activeInputRange.start && inputValue <= activeInputRange.endInclusive) {
+ onInputConfirm(valueText.trim())
+ showInputDialog = false
+ holdArrow = false
+ }
+ },
+ colors = ButtonDefaults.textButtonColorsPrimary(),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt
new file mode 100644
index 0000000..1d4fca2
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/AppSettingsStore.kt
@@ -0,0 +1,214 @@
+package io.github.miuzarte.scrcpyforandroid.services
+
+import android.content.Context
+import androidx.core.content.edit
+import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
+import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
+
+internal data class MainSettings(
+ val themeBaseIndex: Int = AppDefaults.DefaultThemeBaseIndex,
+ val monetEnabled: Boolean = AppDefaults.DefaultMonetEnabled,
+ val fullscreenDebugInfoEnabled: Boolean = AppDefaults.DefaultFullscreenDebugInfoEnabled,
+ val devicePreviewCardHeightDp: Int = AppDefaults.DefaultDevicePreviewCardHeightDp,
+ val keepScreenOnWhenStreamingEnabled: Boolean = AppDefaults.DefaultKeepScreenOnWhenStreamingEnabled,
+ val virtualButtonsOutside: String = AppDefaults.DefaultVirtualButtonsOutside,
+ val virtualButtonsInMore: String = AppDefaults.DefaultVirtualButtonsInMore,
+ val customServerUri: String? = null,
+ val serverRemotePath: String = AppDefaults.DefaultServerRemotePathInput,
+ val videoCodec: String = AppDefaults.DefaultVideoCodec,
+ val audioEnabled: Boolean = AppDefaults.DefaultAudioEnabled,
+ val audioCodec: String = AppDefaults.DefaultAudioCodec,
+ val adbKeyName: String = AppDefaults.DefaultAdbKeyNameInput,
+)
+
+internal data class DevicePageSettings(
+ val pairHost: String = AppDefaults.DefaultPairHost,
+ val pairPort: String = AppDefaults.DefaultPairPort,
+ val pairCode: String = AppDefaults.DefaultPairCode,
+ val quickConnectInput: String = AppDefaults.DefaultQuickConnectInput,
+ val bitRateMbps: Float = AppDefaults.DefaultBitRateMbps,
+ val bitRateInput: String = AppDefaults.DefaultBitRateInput,
+ val audioBitRateKbps: Int = AppDefaults.DefaultAudioBitRateKbps,
+ val maxSizeInput: String = AppDefaults.DefaultMaxSizeInput,
+ val maxFpsInput: String = AppDefaults.DefaultMaxFpsInput,
+ val noControl: Boolean = AppDefaults.DefaultNoControl,
+ val videoEncoder: String = AppDefaults.DefaultVideoEncoder,
+ val videoCodecOptions: String = AppDefaults.DefaultVideoCodecOptions,
+ val audioEncoder: String = AppDefaults.DefaultAudioEncoder,
+ val audioCodecOptions: String = AppDefaults.DefaultAudioCodecOptions,
+ val audioDup: Boolean = AppDefaults.DefaultAudioDup,
+ val audioSourcePreset: String = AppDefaults.DefaultAudioSourcePreset,
+ val audioSourceCustom: String = AppDefaults.DefaultAudioSourceCustom,
+ val videoSourcePreset: String = AppDefaults.DefaultVideoSourcePreset,
+ val cameraIdInput: String = AppDefaults.DefaultCameraIdInput,
+ val cameraFacingPreset: String = AppDefaults.DefaultCameraFacingPreset,
+ val cameraSizePreset: String = AppDefaults.DefaultCameraSizePreset,
+ val cameraSizeCustom: String = AppDefaults.DefaultCameraSizeCustom,
+ val cameraArInput: String = AppDefaults.DefaultCameraArInput,
+ val cameraFpsInput: String = AppDefaults.DefaultCameraFpsInput,
+ val cameraHighSpeed: Boolean = AppDefaults.DefaultCameraHighSpeed,
+ val noAudioPlayback: Boolean = AppDefaults.DefaultNoAudioPlayback,
+ val noVideo: Boolean = AppDefaults.DefaultNoVideo,
+ val requireAudio: Boolean = AppDefaults.DefaultRequireAudio,
+ val turnScreenOff: Boolean = AppDefaults.DefaultTurnScreenOff,
+ val newDisplayWidth: String = AppDefaults.DefaultNewDisplayWidth,
+ val newDisplayHeight: String = AppDefaults.DefaultNewDisplayHeight,
+ val newDisplayDpi: String = AppDefaults.DefaultNewDisplayDpi,
+ val displayIdInput: String = AppDefaults.DefaultDisplayIdInput,
+ val cropWidth: String = AppDefaults.DefaultCropWidth,
+ val cropHeight: String = AppDefaults.DefaultCropHeight,
+ val cropX: String = AppDefaults.DefaultCropX,
+ val cropY: String = AppDefaults.DefaultCropY,
+)
+
+internal fun loadMainSettings(context: Context): MainSettings {
+ val prefs = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
+ return MainSettings(
+ themeBaseIndex = prefs.getInt(AppPreferenceKeys.ThemeBaseIndex, AppDefaults.DefaultThemeBaseIndex),
+ monetEnabled = prefs.getBoolean(AppPreferenceKeys.MonetEnabled, AppDefaults.DefaultMonetEnabled),
+ fullscreenDebugInfoEnabled = prefs.getBoolean(
+ AppPreferenceKeys.FullscreenDebugInfoEnabled,
+ AppDefaults.DefaultFullscreenDebugInfoEnabled,
+ ),
+ devicePreviewCardHeightDp = prefs.getInt(
+ AppPreferenceKeys.DevicePreviewCardHeightDp,
+ AppDefaults.DefaultDevicePreviewCardHeightDp,
+ ).coerceAtLeast(120),
+ keepScreenOnWhenStreamingEnabled = prefs.getBoolean(
+ AppPreferenceKeys.KeepScreenOnWhenStreamingEnabled,
+ AppDefaults.DefaultKeepScreenOnWhenStreamingEnabled,
+ ),
+ virtualButtonsOutside = prefs.getString(
+ AppPreferenceKeys.VirtualButtonsOutside,
+ AppDefaults.DefaultVirtualButtonsOutside,
+ ).orEmpty().ifBlank { AppDefaults.DefaultVirtualButtonsOutside },
+ virtualButtonsInMore = prefs.getString(
+ AppPreferenceKeys.VirtualButtonsInMore,
+ AppDefaults.DefaultVirtualButtonsInMore,
+ ).orEmpty().ifBlank { AppDefaults.DefaultVirtualButtonsInMore },
+ customServerUri = prefs.getString(AppPreferenceKeys.CustomServerUri, null),
+ serverRemotePath = prefs.getString(
+ AppPreferenceKeys.ServerRemotePath,
+ AppDefaults.DefaultServerRemotePathInput,
+ ).orEmpty(),
+ videoCodec = prefs.getString(AppPreferenceKeys.VideoCodec, AppDefaults.DefaultVideoCodec)
+ .orEmpty()
+ .ifBlank { AppDefaults.DefaultVideoCodec },
+ audioEnabled = prefs.getBoolean(AppPreferenceKeys.AudioEnabled, AppDefaults.DefaultAudioEnabled),
+ audioCodec = prefs.getString(AppPreferenceKeys.AudioCodec, AppDefaults.DefaultAudioCodec)
+ .orEmpty()
+ .ifBlank { AppDefaults.DefaultAudioCodec },
+ adbKeyName = prefs.getString(AppPreferenceKeys.AdbKeyName, AppDefaults.DefaultAdbKeyNameInput).orEmpty(),
+ )
+}
+
+internal fun saveMainSettings(context: Context, settings: MainSettings) {
+ context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
+ .edit {
+ putInt(AppPreferenceKeys.ThemeBaseIndex, settings.themeBaseIndex)
+ .putBoolean(AppPreferenceKeys.MonetEnabled, settings.monetEnabled)
+ .putBoolean(AppPreferenceKeys.FullscreenDebugInfoEnabled, settings.fullscreenDebugInfoEnabled)
+ .putInt(AppPreferenceKeys.DevicePreviewCardHeightDp, settings.devicePreviewCardHeightDp.coerceAtLeast(120))
+ .putBoolean(AppPreferenceKeys.KeepScreenOnWhenStreamingEnabled, settings.keepScreenOnWhenStreamingEnabled)
+ .putString(AppPreferenceKeys.VirtualButtonsOutside, settings.virtualButtonsOutside)
+ .putString(AppPreferenceKeys.VirtualButtonsInMore, settings.virtualButtonsInMore)
+ .putString(AppPreferenceKeys.CustomServerUri, settings.customServerUri)
+ .putString(AppPreferenceKeys.ServerRemotePath, settings.serverRemotePath)
+ .putString(AppPreferenceKeys.VideoCodec, settings.videoCodec)
+ .putBoolean(AppPreferenceKeys.AudioEnabled, settings.audioEnabled)
+ .putString(AppPreferenceKeys.AudioCodec, settings.audioCodec)
+ .putString(AppPreferenceKeys.AdbKeyName, settings.adbKeyName)
+ }
+}
+
+internal fun loadDevicePageSettings(context: Context): DevicePageSettings {
+ val prefs = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
+ return DevicePageSettings(
+ pairHost = AppDefaults.DefaultPairHost,
+ pairPort = AppDefaults.DefaultPairPort,
+ pairCode = AppDefaults.DefaultPairCode,
+ quickConnectInput = prefs.getString(AppPreferenceKeys.QuickConnectInput, AppDefaults.DefaultQuickConnectInput).orEmpty(),
+ bitRateMbps = prefs.getFloat(AppPreferenceKeys.BitRateMbps, AppDefaults.DefaultBitRateMbps),
+ bitRateInput = prefs.getString(AppPreferenceKeys.BitRateInput, AppDefaults.DefaultBitRateInput)
+ .orEmpty()
+ .ifBlank { AppDefaults.DefaultBitRateInput },
+ maxSizeInput = prefs.getString(AppPreferenceKeys.MaxSizeInput, AppDefaults.DefaultMaxSizeInput).orEmpty(),
+ audioBitRateKbps = prefs.getInt(AppPreferenceKeys.AudioBitRateKbps, AppDefaults.DefaultAudioBitRateKbps),
+ maxFpsInput = prefs.getString(AppPreferenceKeys.MaxFpsInput, AppDefaults.DefaultMaxFpsInput).orEmpty(),
+ noControl = prefs.getBoolean(AppPreferenceKeys.NoControl, AppDefaults.DefaultNoControl),
+ videoEncoder = prefs.getString(AppPreferenceKeys.VideoEncoder, AppDefaults.DefaultVideoEncoder).orEmpty(),
+ videoCodecOptions = prefs.getString(AppPreferenceKeys.VideoCodecOptions, AppDefaults.DefaultVideoCodecOptions).orEmpty(),
+ audioEncoder = prefs.getString(AppPreferenceKeys.AudioEncoder, AppDefaults.DefaultAudioEncoder).orEmpty(),
+ audioCodecOptions = prefs.getString(AppPreferenceKeys.AudioCodecOptions, AppDefaults.DefaultAudioCodecOptions).orEmpty(),
+ audioDup = prefs.getBoolean(AppPreferenceKeys.AudioDup, AppDefaults.DefaultAudioDup),
+ audioSourcePreset = prefs.getString(AppPreferenceKeys.AudioSourcePreset, AppDefaults.DefaultAudioSourcePreset)
+ .orEmpty()
+ .ifBlank { AppDefaults.DefaultAudioSourcePreset },
+ audioSourceCustom = prefs.getString(AppPreferenceKeys.AudioSourceCustom, AppDefaults.DefaultAudioSourceCustom).orEmpty(),
+ videoSourcePreset = prefs.getString(AppPreferenceKeys.VideoSourcePreset, AppDefaults.DefaultVideoSourcePreset)
+ .orEmpty()
+ .ifBlank { AppDefaults.DefaultVideoSourcePreset },
+ cameraIdInput = prefs.getString(AppPreferenceKeys.CameraIdInput, AppDefaults.DefaultCameraIdInput).orEmpty(),
+ cameraFacingPreset = prefs.getString(AppPreferenceKeys.CameraFacingPreset, AppDefaults.DefaultCameraFacingPreset).orEmpty(),
+ cameraSizePreset = prefs.getString(AppPreferenceKeys.CameraSizePreset, AppDefaults.DefaultCameraSizePreset).orEmpty(),
+ cameraSizeCustom = prefs.getString(AppPreferenceKeys.CameraSizeCustom, AppDefaults.DefaultCameraSizeCustom).orEmpty(),
+ cameraArInput = prefs.getString(AppPreferenceKeys.CameraArInput, AppDefaults.DefaultCameraArInput).orEmpty(),
+ cameraFpsInput = prefs.getString(AppPreferenceKeys.CameraFpsInput, AppDefaults.DefaultCameraFpsInput).orEmpty(),
+ cameraHighSpeed = prefs.getBoolean(AppPreferenceKeys.CameraHighSpeed, AppDefaults.DefaultCameraHighSpeed),
+ noAudioPlayback = prefs.getBoolean(AppPreferenceKeys.NoAudioPlayback, AppDefaults.DefaultNoAudioPlayback),
+ noVideo = prefs.getBoolean(AppPreferenceKeys.NoVideo, AppDefaults.DefaultNoVideo),
+ requireAudio = prefs.getBoolean(AppPreferenceKeys.RequireAudio, AppDefaults.DefaultRequireAudio),
+ turnScreenOff = prefs.getBoolean(AppPreferenceKeys.TurnScreenOff, AppDefaults.DefaultTurnScreenOff),
+ newDisplayWidth = prefs.getString(AppPreferenceKeys.NewDisplayWidth, AppDefaults.DefaultNewDisplayWidth).orEmpty(),
+ newDisplayHeight = prefs.getString(AppPreferenceKeys.NewDisplayHeight, AppDefaults.DefaultNewDisplayHeight).orEmpty(),
+ newDisplayDpi = prefs.getString(AppPreferenceKeys.NewDisplayDpi, AppDefaults.DefaultNewDisplayDpi).orEmpty(),
+ displayIdInput = prefs.getString(AppPreferenceKeys.DisplayIdInput, AppDefaults.DefaultDisplayIdInput).orEmpty(),
+ cropWidth = prefs.getString(AppPreferenceKeys.CropWidth, AppDefaults.DefaultCropWidth).orEmpty(),
+ cropHeight = prefs.getString(AppPreferenceKeys.CropHeight, AppDefaults.DefaultCropHeight).orEmpty(),
+ cropX = prefs.getString(AppPreferenceKeys.CropX, AppDefaults.DefaultCropX).orEmpty(),
+ cropY = prefs.getString(AppPreferenceKeys.CropY, AppDefaults.DefaultCropY).orEmpty(),
+ )
+}
+
+internal fun saveDevicePageSettings(context: Context, settings: DevicePageSettings) {
+ context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
+ .edit {
+ remove(AppPreferenceKeys.PairHost)
+ .remove(AppPreferenceKeys.PairPort)
+ .remove(AppPreferenceKeys.PairCode)
+ .putString(AppPreferenceKeys.QuickConnectInput, settings.quickConnectInput)
+ .putFloat(AppPreferenceKeys.BitRateMbps, settings.bitRateMbps)
+ .putString(AppPreferenceKeys.BitRateInput, settings.bitRateInput)
+ .putInt(AppPreferenceKeys.AudioBitRateKbps, settings.audioBitRateKbps)
+ .putString(AppPreferenceKeys.MaxSizeInput, settings.maxSizeInput)
+ .putString(AppPreferenceKeys.MaxFpsInput, settings.maxFpsInput)
+ .putBoolean(AppPreferenceKeys.NoControl, settings.noControl)
+ .putString(AppPreferenceKeys.VideoEncoder, settings.videoEncoder)
+ .putString(AppPreferenceKeys.VideoCodecOptions, settings.videoCodecOptions)
+ .putString(AppPreferenceKeys.AudioEncoder, settings.audioEncoder)
+ .putString(AppPreferenceKeys.AudioCodecOptions, settings.audioCodecOptions)
+ .putBoolean(AppPreferenceKeys.AudioDup, settings.audioDup)
+ .putString(AppPreferenceKeys.AudioSourcePreset, settings.audioSourcePreset)
+ .putString(AppPreferenceKeys.AudioSourceCustom, settings.audioSourceCustom)
+ .putString(AppPreferenceKeys.VideoSourcePreset, settings.videoSourcePreset)
+ .putString(AppPreferenceKeys.CameraIdInput, settings.cameraIdInput)
+ .putString(AppPreferenceKeys.CameraFacingPreset, settings.cameraFacingPreset)
+ .putString(AppPreferenceKeys.CameraSizePreset, settings.cameraSizePreset)
+ .putString(AppPreferenceKeys.CameraSizeCustom, settings.cameraSizeCustom)
+ .putString(AppPreferenceKeys.CameraArInput, settings.cameraArInput)
+ .putString(AppPreferenceKeys.CameraFpsInput, settings.cameraFpsInput)
+ .putBoolean(AppPreferenceKeys.CameraHighSpeed, settings.cameraHighSpeed)
+ .putBoolean(AppPreferenceKeys.NoAudioPlayback, settings.noAudioPlayback)
+ .putBoolean(AppPreferenceKeys.NoVideo, settings.noVideo)
+ .putBoolean(AppPreferenceKeys.RequireAudio, settings.requireAudio)
+ .putBoolean(AppPreferenceKeys.TurnScreenOff, settings.turnScreenOff)
+ .putString(AppPreferenceKeys.NewDisplayWidth, settings.newDisplayWidth)
+ .putString(AppPreferenceKeys.NewDisplayHeight, settings.newDisplayHeight)
+ .putString(AppPreferenceKeys.NewDisplayDpi, settings.newDisplayDpi)
+ .putString(AppPreferenceKeys.DisplayIdInput, settings.displayIdInput)
+ .putString(AppPreferenceKeys.CropWidth, settings.cropWidth)
+ .putString(AppPreferenceKeys.CropHeight, settings.cropHeight)
+ .putString(AppPreferenceKeys.CropX, settings.cropX)
+ .putString(AppPreferenceKeys.CropY, settings.cropY)
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt
new file mode 100644
index 0000000..6dcaf27
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt
@@ -0,0 +1,35 @@
+package io.github.miuzarte.scrcpyforandroid.services
+
+import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
+
+internal data class ConnectedDeviceInfo(
+ val model: String,
+ val serial: String,
+ val manufacturer: String,
+ val brand: String,
+ val device: String,
+ val androidRelease: String,
+ val sdkInt: Int,
+)
+
+internal fun fetchConnectedDeviceInfo(nativeCore: NativeCoreFacade, host: String, port: Int): ConnectedDeviceInfo {
+ fun prop(name: String): String = runCatching { nativeCore.adbShell("getprop $name").trim() }.getOrDefault("")
+
+ val model = prop("ro.product.model")
+ val serial = prop("ro.serialno")
+ val manufacturer = prop("ro.product.manufacturer")
+ val brand = prop("ro.product.brand")
+ val device = prop("ro.product.device")
+ val androidRelease = prop("ro.build.version.release")
+ val sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1
+
+ return ConnectedDeviceInfo(
+ model = model.ifBlank { "$host:$port" },
+ serial = serial,
+ manufacturer = manufacturer,
+ brand = brand,
+ device = device,
+ androidRelease = androidRelease,
+ sdkInt = sdkInt,
+ )
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt
new file mode 100644
index 0000000..5a2b1bc
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt
@@ -0,0 +1,110 @@
+package io.github.miuzarte.scrcpyforandroid.services
+
+import android.content.Context
+import androidx.core.content.edit
+import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
+import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
+import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
+import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
+
+internal fun loadQuickDevices(context: Context): List {
+ val raw = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
+ .getString(AppPreferenceKeys.QuickDevices, "")
+ .orEmpty()
+
+ if (raw.isBlank()) return emptyList()
+
+ val result = mutableListOf()
+ raw.lineSequence().forEach { line ->
+ val parts = line.split("|", limit = 3)
+ when (parts.size) {
+ 3 -> {
+ val name = parts[0].trim()
+ val host = parts[1].trim()
+ val port = parts[2].trim().toIntOrNull() ?: AppDefaults.DefaultAdbPort
+ if (host.isNotBlank()) {
+ result.add(
+ DeviceShortcut(
+ id = "$host:$port",
+ name = name,
+ host = host,
+ port = port,
+ online = false,
+ ),
+ )
+ }
+ }
+
+ 2 -> {
+ // Backward compatibility with old format: name|host:port
+ val name = parts[0].trim()
+ val host = parts[1].substringBefore(":").trim()
+ val port = parts[1].substringAfter(":", AppDefaults.DefaultAdbPort.toString()).trim().toIntOrNull() ?: AppDefaults.DefaultAdbPort
+ if (host.isNotBlank()) {
+ result.add(
+ DeviceShortcut(
+ id = "$host:$port",
+ name = name,
+ host = host,
+ port = port,
+ online = false,
+ ),
+ )
+ }
+ }
+ }
+ }
+ return result
+}
+
+internal fun saveQuickDevices(context: Context, quickDevices: List) {
+ val raw = quickDevices.joinToString("\n") { "${it.name}|${it.host}|${it.port}" }
+ context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
+ .edit {
+ putString(AppPreferenceKeys.QuickDevices, raw)
+ }
+}
+
+internal fun parseQuickTarget(raw: String): ConnectionTarget? {
+ val value = raw.trim()
+ if (value.isEmpty()) return null
+ val host = value.substringBefore(':').trim()
+ if (host.isEmpty()) return null
+ val port = value.substringAfter(':', AppDefaults.DefaultAdbPort.toString()).trim().toIntOrNull() ?: AppDefaults.DefaultAdbPort
+ return ConnectionTarget(host, port)
+}
+
+internal fun upsertQuickDevice(
+ context: Context,
+ quickDevices: MutableList,
+ host: String,
+ port: Int,
+ online: Boolean,
+) {
+ val id = "$host:$port"
+ val idx = quickDevices.indexOfFirst { it.id == id }
+ val existingName = if (idx >= 0) quickDevices[idx].name else ""
+ val item = DeviceShortcut(
+ id = id,
+ name = existingName,
+ host = host,
+ port = port,
+ online = online,
+ )
+ if (idx >= 0) quickDevices[idx] = item else quickDevices.add(0, item)
+ saveQuickDevices(context, quickDevices)
+}
+
+internal fun updateQuickDeviceNameIfEmpty(
+ context: Context,
+ quickDevices: MutableList,
+ host: String,
+ port: Int,
+ fallbackName: String,
+) {
+ val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
+ if (idx >= 0 && quickDevices[idx].name.isBlank()) {
+ quickDevices[idx] = quickDevices[idx].copy(name = fallbackName)
+ saveQuickDevices(context, quickDevices)
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
new file mode 100644
index 0000000..e1180df
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt
@@ -0,0 +1,1055 @@
+package io.github.miuzarte.scrcpyforandroid.widgets
+
+import android.annotation.SuppressLint
+import android.graphics.SurfaceTexture
+import android.view.MotionEvent
+import android.view.Surface
+import android.view.TextureView
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.AddLink
+import androidx.compose.material.icons.filled.Fullscreen
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.rounded.CheckCircleOutline
+import androidx.compose.material.icons.rounded.LinkOff
+import androidx.compose.material.icons.rounded.Wifi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.pointerInteropFilter
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
+import io.github.miuzarte.scrcpyforandroid.ScrcpySessionInfo
+import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
+import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
+import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
+import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
+import top.yukonga.miuix.kmp.basic.Button
+import top.yukonga.miuix.kmp.basic.ButtonDefaults
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextButton
+import top.yukonga.miuix.kmp.basic.TextField
+import top.yukonga.miuix.kmp.extra.SuperArrow
+import top.yukonga.miuix.kmp.extra.SuperDialog
+import top.yukonga.miuix.kmp.extra.SuperDropdown
+import top.yukonga.miuix.kmp.extra.SuperSwitch
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
+import top.yukonga.miuix.kmp.utils.PressFeedbackType
+import kotlin.math.roundToInt
+
+private val VIDEO_CODEC_OPTIONS = listOf(
+ "h264" to "H.264",
+ "h265" to "H.265",
+ "av1" to "AV1",
+)
+
+private val AUDIO_CODEC_OPTIONS = listOf(
+ "opus" to "Opus",
+ "aac" to "AAC",
+ "flac" to "FLAC",
+ "raw" to "RAW",
+)
+
+private object UiMotionActions {
+ const val DOWN = 0
+ const val UP = 1
+ const val MOVE = 2
+ const val CANCEL = 3
+ const val POINTER_DOWN = 5
+ const val POINTER_UP = 6
+}
+
+@Composable
+internal fun StatusCard(
+ statusLine: String,
+ adbConnected: Boolean,
+ streaming: Boolean,
+ sessionInfo: ScrcpySessionInfo?,
+ busyLabel: String?,
+ connectedDeviceLabel: String,
+) {
+ val cleanStatusLine = normalizeStatusLine(statusLine)
+ val spec = when {
+ streaming && sessionInfo != null -> {
+ val streamCardColor = when {
+ isDynamicColor -> MiuixTheme.colorScheme.secondaryContainer
+ isSystemInDarkTheme() -> Color(0xFF1A3825)
+ else -> Color(0xFFDFFAE4)
+ }
+ val streamTextColor = when {
+ isDynamicColor -> MiuixTheme.colorScheme.onSecondaryContainer
+ isSystemInDarkTheme() -> Color.White
+ else -> Color(0xFF111111)
+ }
+ val streamIconColor = if (isDynamicColor) {
+ MiuixTheme.colorScheme.primary.copy(alpha = 0.8f)
+ } else {
+ Color(0xFF36D167)
+ }
+ StatusCardSpec(
+ big = StatusBigCardSpec(
+ title = "投屏中 (视频流)",
+ subtitle = sessionInfo.deviceName,
+ containerColor = streamCardColor,
+ titleColor = streamTextColor,
+ subtitleColor = streamTextColor,
+ icon = Icons.Rounded.CheckCircleOutline,
+ iconTint = streamIconColor,
+ ),
+ firstSmall = StatusSmallCardSpec(
+ "分辨率",
+ "${sessionInfo.width}×${sessionInfo.height}",
+ ),
+ secondSmall = StatusSmallCardSpec(
+ "编解码器",
+ sessionInfo.codec,
+ ),
+ )
+ }
+
+ adbConnected -> StatusCardSpec(
+ big = StatusBigCardSpec(
+ title = "ADB 已连接",
+ subtitle = cleanStatusLine,
+ containerColor = MiuixTheme.colorScheme.primaryContainer,
+ titleColor = MiuixTheme.colorScheme.onPrimaryContainer,
+ subtitleColor = MiuixTheme.colorScheme.onPrimaryContainer,
+ icon = Icons.Rounded.Wifi,
+ iconTint = MiuixTheme.colorScheme.primary.copy(alpha = 0.6f),
+ ),
+ firstSmall = StatusSmallCardSpec(
+ "当前设备",
+ connectedDeviceLabel,
+ ),
+ secondSmall = StatusSmallCardSpec(
+ "状态",
+ "空闲",
+ ),
+ )
+
+ else -> StatusCardSpec(
+ big = StatusBigCardSpec(
+ title = "ADB 未连接",
+ subtitle = "",
+ containerColor = MiuixTheme.colorScheme.secondaryContainer,
+ titleColor = MiuixTheme.colorScheme.onSecondaryContainer,
+ subtitleColor = MiuixTheme.colorScheme.onSecondaryContainer,
+ icon = Icons.Rounded.LinkOff,
+ iconTint = MiuixTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f),
+ ),
+ firstSmall = StatusSmallCardSpec(
+ "当前设备",
+ "N/A",
+ ),
+ secondSmall = StatusSmallCardSpec(
+ "状态",
+ "N/A",
+ ),
+ )
+ }
+
+ StatusCardLayout(spec = spec, busyLabel = busyLabel)
+}
+
+@Composable
+internal fun PairingCard(
+ busy: Boolean,
+ onPair: (host: String, port: String, code: String) -> Unit,
+) {
+ val showPairDialog = remember { mutableStateOf(false) }
+ val holdDownState = remember { mutableStateOf(false) }
+
+ Card {
+ SuperArrow(
+ title = "使用配对码配对设备",
+ onClick = {
+ showPairDialog.value = true
+ holdDownState.value = true
+ },
+ holdDownState = holdDownState.value,
+ enabled = !busy,
+ )
+ }
+
+ PairingDialog(
+ showDialog = showPairDialog.value,
+ enabled = !busy,
+ onDismissRequest = { showPairDialog.value = false },
+ onDismissFinished = { holdDownState.value = false },
+ onConfirm = { host, port, code ->
+ showPairDialog.value = false
+ onPair(host, port, code)
+ },
+ )
+}
+
+@Composable
+internal fun PreviewCard(
+ sessionInfo: ScrcpySessionInfo?,
+ nativeCore: NativeCoreFacade,
+ previewHeightDp: Int,
+ controlsVisible: Boolean,
+ onTapped: () -> Unit,
+ onOpenFullscreenHaptic: (() -> Unit)? = null,
+ onOpenFullscreen: () -> Unit,
+) {
+ val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls")
+
+ Card {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(previewHeightDp.coerceAtLeast(120).dp)
+ .pointerInput(sessionInfo) { detectTapGestures(onTap = { onTapped() }) },
+ ) {
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val sessionAspect = if (sessionInfo == null || sessionInfo.height == 0) {
+ 16f / 9f
+ } else {
+ sessionInfo.width.toFloat() / sessionInfo.height.toFloat()
+ }
+ val containerAspect = maxWidth.value / maxHeight.value
+ val fittedModifier = if (sessionAspect > containerAspect) {
+ Modifier.fillMaxWidth().aspectRatio(sessionAspect)
+ } else {
+ Modifier.fillMaxHeight().aspectRatio(sessionAspect)
+ }
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .then(fittedModifier),
+ ) {
+ ScrcpyVideoSurface(
+ modifier = Modifier.fillMaxSize(),
+ nativeCore = nativeCore,
+ session = sessionInfo,
+ )
+ }
+ }
+
+ if (sessionInfo != null) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(UiSpacing.CardContent),
+ ) {
+ Button(
+ onClick = {
+ onOpenFullscreenHaptic?.invoke()
+ onOpenFullscreen()
+ },
+ modifier = Modifier.alpha(alpha),
+ ) {
+ Icon(Icons.Default.Fullscreen, contentDescription = "全屏")
+ Spacer(Modifier.width(UiSpacing.SectionTitleBottom))
+ Text("全屏")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun VirtualButtonCard(
+ busy: Boolean,
+ outsideActions: List,
+ moreActions: List,
+ onPressHaptic: () -> Unit,
+ onConfirmHaptic: () -> Unit,
+ onAction: (VirtualButtonAction) -> Unit,
+) {
+ val bar = remember(outsideActions, moreActions) {
+ VirtualButtonBar(
+ outsideActions = outsideActions,
+ moreActions = moreActions,
+ )
+ }
+
+ Card {
+ bar.Preview(
+ enabled = !busy,
+ onPressHaptic = onPressHaptic,
+ onConfirmHaptic = onConfirmHaptic,
+ onAction = onAction,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(UiSpacing.CardContent),
+ )
+ }
+}
+
+@Composable
+internal fun ConfigPanel(
+ busy: Boolean,
+ bitRateMbps: Float,
+ onBitRateSliderChange: (Float) -> Unit,
+ onBitRateInputChange: (String) -> Unit,
+ audioBitRateKbps: Int,
+ onAudioBitRateChange: (Int) -> Unit,
+ videoCodec: String,
+ onVideoCodecChange: (String) -> Unit,
+ audioEnabled: Boolean,
+ onAudioEnabledChange: (Boolean) -> Unit,
+ audioForwardingSupported: Boolean,
+ audioCodec: String,
+ onAudioCodecChange: (String) -> Unit,
+ onOpenAdvanced: () -> Unit,
+ onStartStopHaptic: (() -> Unit)? = null,
+ onStart: () -> Unit,
+ onStop: () -> Unit,
+ sessionStarted: Boolean,
+) {
+ val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } }
+ val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst { it.first == videoCodec }.coerceAtLeast(0)
+ val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } }
+ val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst { it.first == audioCodec }.coerceAtLeast(0)
+ val audioBitRatePresetIndex = presetIndexFromInput(audioBitRateKbps.toString(), ScrcpyPresets.AudioBitRate)
+
+ SectionSmallTitle(text = "Scrcpy")
+ Card {
+ SuperSwitch(
+ title = "音频转发",
+ summary = "转发设备音频到本机 (Android 11+)",
+ checked = audioEnabled,
+ onCheckedChange = onAudioEnabledChange,
+ enabled = !sessionStarted && audioForwardingSupported,
+ )
+ SuperDropdown(
+ title = "音频编码",
+ summary = "--audio-codec",
+ items = audioCodecItems,
+ selectedIndex = audioCodecIndex,
+ onSelectedIndexChange = { onAudioCodecChange(AUDIO_CODEC_OPTIONS[it].first) },
+ enabled = !sessionStarted && audioEnabled,
+ )
+ if (audioEnabled && (audioCodec == "opus" || audioCodec == "aac")) {
+ SuperSlide(
+ title = "音频码率",
+ summary = "--audio-bit-rate",
+ value = audioBitRatePresetIndex.toFloat(),
+ onValueChange = { value ->
+ val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
+ onAudioBitRateChange(ScrcpyPresets.AudioBitRate[idx])
+ },
+ valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
+ steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
+ enabled = !sessionStarted,
+ unit = "Kbps",
+ displayText = audioBitRateKbps.toString(),
+ inputInitialValue = audioBitRateKbps.toString(),
+ inputFilter = { it.filter(Char::isDigit) },
+ inputValueRange = 1f..Float.MAX_VALUE,
+ onInputConfirm = { raw ->
+ raw.toIntOrNull()?.takeIf { it > 0 }?.let { onAudioBitRateChange(it) }
+ },
+ )
+ }
+ SuperDropdown(
+ title = "视频编码",
+ summary = "--video-codec",
+ items = videoCodecItems,
+ selectedIndex = videoCodecIndex,
+ onSelectedIndexChange = { onVideoCodecChange(VIDEO_CODEC_OPTIONS[it].first) },
+ enabled = !sessionStarted,
+ )
+ SuperSlide(
+ title = "视频码率",
+ summary = "--video-bit-rate",
+ value = bitRateMbps,
+ onValueChange = {
+ onBitRateSliderChange(it)
+ onBitRateInputChange(formatBitRate(it))
+ },
+ valueRange = 0.1f..40f,
+ steps = 399,
+ enabled = !sessionStarted,
+ unit = "Mbps",
+ displayFormatter = { formatBitRate(it) },
+ inputInitialValue = formatBitRate(bitRateMbps),
+ inputFilter = { text ->
+ var dotUsed = false
+ text.filter { ch ->
+ when {
+ ch.isDigit() -> true
+ ch == '.' && !dotUsed -> {
+ dotUsed = true
+ true
+ }
+ else -> false
+ }
+ }
+ },
+ inputValueRange = 0.1f..Float.MAX_VALUE,
+ onInputConfirm = { raw ->
+ raw.toFloatOrNull()?.let { parsed ->
+ if (parsed >= 0.1f) {
+ onBitRateSliderChange(parsed)
+ onBitRateInputChange(formatBitRate(parsed))
+ }
+ }
+ },
+ )
+ SuperArrow(
+ title = "高级参数",
+ summary = "更多 scrcpy 启动参数",
+ onClick = onOpenAdvanced,
+ enabled = !sessionStarted,
+ )
+ TextButton(
+ text = if (sessionStarted) "停止" else "启动",
+ onClick = {
+ onStartStopHaptic?.invoke()
+ if (sessionStarted) onStop() else onStart()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ enabled = !busy,
+ colors = if (sessionStarted) {
+ ButtonDefaults.textButtonColors()
+ } else {
+ ButtonDefaults.textButtonColorsPrimary()
+ },
+ )
+ }
+}
+
+@Composable
+private fun PairingDialog(
+ showDialog: Boolean,
+ enabled: Boolean,
+ onDismissRequest: () -> Unit,
+ onDismissFinished: () -> Unit,
+ onConfirm: (host: String, port: String, code: String) -> Unit,
+) {
+ var host by rememberSaveable(showDialog) { mutableStateOf("") }
+ var port by rememberSaveable(showDialog) { mutableStateOf("") }
+ var code by rememberSaveable(showDialog) { mutableStateOf("") }
+
+ fun clearInputs() {
+ host = ""
+ port = ""
+ code = ""
+ }
+
+ SuperDialog(
+ show = showDialog,
+ title = "使用配对码配对设备",
+ summary = "使用六位数的配对码配对新设备",
+ onDismissRequest = {
+ clearInputs()
+ onDismissRequest()
+ },
+ onDismissFinished = onDismissFinished,
+ content = {
+ TextField(
+ value = host,
+ onValueChange = { host = it },
+ label = "IP 地址",
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth().padding(bottom = UiSpacing.CardContent),
+ )
+ TextField(
+ value = port,
+ onValueChange = { port = it.filter(Char::isDigit) },
+ label = "端口",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth().padding(bottom = UiSpacing.CardContent),
+ )
+ TextField(
+ value = code,
+ onValueChange = { code = it },
+ label = "WLAN 配对码",
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth().padding(bottom = UiSpacing.Large),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal)) {
+ TextButton(
+ text = "取消",
+ onClick = {
+ clearInputs()
+ onDismissRequest()
+ },
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ text = "配对",
+ onClick = {
+ onConfirm(host.trim(), port.trim().ifBlank { "37099" }, code.trim())
+ clearInputs()
+ },
+ enabled = enabled && host.isNotBlank() && code.isNotBlank(),
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.textButtonColorsPrimary(),
+ )
+ }
+ },
+ )
+}
+
+private fun presetIndexFromInput(raw: String, presets: List): Int {
+ if (raw.isBlank()) return 0
+ val value = raw.toIntOrNull() ?: return 0
+ val exact = presets.indexOf(value)
+ if (exact >= 0) return exact
+ val nearest = presets.withIndex().minByOrNull { (_, preset) -> kotlin.math.abs(preset - value) }
+ return nearest?.index ?: 0
+}
+
+@SuppressLint("DefaultLocale")
+private fun formatBitRate(value: Float): String = String.format("%.1f", value)
+
+@Composable
+internal fun LogsPanel(lines: List) {
+ Card(
+ pressFeedbackType = PressFeedbackType.Sink,
+ ) {
+ TextField(
+ value = lines.joinToString(separator = "\n"),
+ onValueChange = {},
+ readOnly = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Composable
+fun FullscreenControlScreen(
+ session: ScrcpySessionInfo,
+ nativeCore: NativeCoreFacade,
+ onDismiss: () -> Unit,
+ showDebugInfo: Boolean,
+ currentFps: Float,
+ enableBackHandler: Boolean = true,
+ onInjectTouch: (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
+) {
+ var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
+ val activePointerIds = remember { linkedSetOf() }
+ val activePointerPositions = remember { linkedMapOf() }
+ var activeTouchCount by remember { mutableIntStateOf(0) }
+ BackHandler(enabled = enableBackHandler, onBack = onDismiss)
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .pointerInteropFilter { event ->
+ if (touchAreaSize.width == 0 || touchAreaSize.height == 0) {
+ return@pointerInteropFilter true
+ }
+
+ fun mapToDevice(rawX: Float, rawY: Float): Pair {
+ val x = ((rawX / touchAreaSize.width) * session.width).roundToInt().coerceIn(0, (session.width - 1).coerceAtLeast(0))
+ val y = ((rawY / touchAreaSize.height) * session.height).roundToInt().coerceIn(0, (session.height - 1).coerceAtLeast(0))
+ return x to y
+ }
+
+ fun syncActivePointersFromEvent(skipPointerId: Int? = null) {
+ for (i in 0 until event.pointerCount) {
+ val pointerId = event.getPointerId(i)
+ if (!activePointerIds.contains(pointerId) || pointerId == skipPointerId) continue
+ val px = event.getX(i)
+ val py = event.getY(i)
+ activePointerPositions[pointerId] = Offset(px, py)
+ val (x, y) = mapToDevice(px, py)
+ onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, event.getPressure(i).coerceIn(0f, 1f), 1)
+ }
+ }
+
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ activePointerIds.clear()
+ activePointerPositions.clear()
+ activeTouchCount = 0
+ val (x, y) = mapToDevice(event.x, event.y)
+ val pointerId = event.getPointerId(0)
+ activePointerIds += pointerId
+ activePointerPositions[pointerId] = Offset(event.x, event.y)
+ activeTouchCount = activePointerIds.size
+ onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, event.getPressure(0).coerceIn(0f, 1f), 1)
+ }
+
+ MotionEvent.ACTION_POINTER_DOWN -> {
+ val index = event.actionIndex
+ val px = event.getX(index)
+ val py = event.getY(index)
+ val (x, y) = mapToDevice(px, py)
+ val pointerId = event.getPointerId(index)
+ activePointerIds += pointerId
+ activePointerPositions[pointerId] = Offset(px, py)
+ activeTouchCount = activePointerIds.size
+ onInjectTouch(UiMotionActions.POINTER_DOWN, pointerId.toLong(), x, y, event.getPressure(index).coerceIn(0f, 1f), 1)
+ syncActivePointersFromEvent()
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ for (i in 0 until event.pointerCount) {
+ val pointerId = event.getPointerId(i)
+ if (!activePointerIds.contains(pointerId)) continue
+ val px = event.getX(i)
+ val py = event.getY(i)
+ activePointerPositions[pointerId] = Offset(px, py)
+ val (x, y) = mapToDevice(px, py)
+ onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, event.getPressure(i).coerceIn(0f, 1f), 1)
+ }
+ }
+
+ MotionEvent.ACTION_POINTER_UP -> {
+ val index = event.actionIndex
+ val px = event.getX(index)
+ val py = event.getY(index)
+ val (x, y) = mapToDevice(px, py)
+ val pointerId = event.getPointerId(index)
+ onInjectTouch(UiMotionActions.POINTER_UP, pointerId.toLong(), x, y, 0f, 1)
+ activePointerIds -= pointerId
+ activePointerPositions.remove(pointerId)
+ activeTouchCount = activePointerIds.size
+ syncActivePointersFromEvent(skipPointerId = pointerId)
+ }
+
+ MotionEvent.ACTION_UP -> {
+ val (x, y) = mapToDevice(event.x, event.y)
+ val pointerId = event.getPointerId(0)
+ onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 1)
+ activePointerIds.clear()
+ activePointerPositions.clear()
+ activeTouchCount = 0
+ }
+
+ MotionEvent.ACTION_CANCEL -> {
+ for (pointerId in activePointerIds) {
+ val pos = activePointerPositions[pointerId] ?: Offset.Zero
+ val (x, y) = mapToDevice(pos.x, pos.y)
+ onInjectTouch(UiMotionActions.CANCEL, pointerId.toLong(), x, y, 0f, 0)
+ }
+ activePointerIds.clear()
+ activePointerPositions.clear()
+ activeTouchCount = 0
+ }
+ }
+ true
+ }
+ .onSizeChanged { touchAreaSize = it },
+ ) {
+ val sessionAspect = if (session.height == 0) {
+ 16f / 9f
+ } else {
+ session.width.toFloat() / session.height.toFloat()
+ }
+ val containerAspect = maxWidth.value / maxHeight.value
+ val fittedModifier = if (sessionAspect > containerAspect) {
+ Modifier.fillMaxWidth().aspectRatio(sessionAspect)
+ } else {
+ Modifier.fillMaxHeight().aspectRatio(sessionAspect)
+ }
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .then(fittedModifier),
+ ) {
+ ScrcpyVideoSurface(
+ modifier = Modifier.fillMaxSize(),
+ nativeCore = nativeCore,
+ session = session,
+ )
+ }
+
+ if (showDebugInfo) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent)
+ .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(25))
+ .padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.Medium),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
+ Text(
+ text = "分辨率: ${session.width}x${session.height}",
+ color = Color.White,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = "触点: $activeTouchCount",
+ color = Color.White,
+ fontSize = 13.sp,
+ )
+ @SuppressLint("DefaultLocale")
+ Text(
+ text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}",
+ color = Color.White,
+ fontSize = 13.sp,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScrcpyVideoSurface(
+ modifier: Modifier,
+ nativeCore: NativeCoreFacade,
+ session: ScrcpySessionInfo?,
+) {
+ val surfaceTag = "video-main"
+ var currentSurface by remember { mutableStateOf(null) }
+
+ LaunchedEffect(session, currentSurface) {
+ if (session != null && currentSurface != null) {
+ nativeCore.registerVideoSurface(surfaceTag, currentSurface!!)
+ }
+ // Unregistration is handled directly in onSurfaceTextureDestroyed and DisposableEffect
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ val released = currentSurface
+ if (released != null) {
+ nativeCore.unregisterVideoSurface(surfaceTag, released)
+ released.release()
+ currentSurface = null
+ }
+ // If currentSurface is null, onSurfaceTextureDestroyed already handled cleanup
+ }
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ TextureView(context).apply {
+ surfaceTextureListener = object : TextureView.SurfaceTextureListener {
+ override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
+ currentSurface?.release() // Release stale surface if any
+ @SuppressLint("Recycle")
+ currentSurface = Surface(surfaceTexture)
+ }
+
+ override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) = Unit
+
+ override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
+ val released = currentSurface
+ currentSurface = null
+ if (released != null) {
+ nativeCore.unregisterVideoSurface(surfaceTag, released)
+ released.release()
+ }
+ return true
+ }
+
+ override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit
+ }
+ }
+ },
+ update = {},
+ )
+}
+
+@Composable
+internal fun DeviceTile(
+ device: DeviceShortcut,
+ actionText: String,
+ actionEnabled: Boolean,
+ actionInProgress: Boolean,
+ onLongPress: () -> Unit,
+ onContentClick: () -> Unit,
+ onAction: () -> Unit,
+) {
+ val haptics = rememberAppHaptics()
+ Card(
+ colors = CardDefaults.defaultColors(
+ color = if (device.online) MiuixTheme.colorScheme.surfaceContainer
+ else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f),
+ ),
+ pressFeedbackType = PressFeedbackType.Sink,
+ onClick = haptics.press,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onContentClick,
+ onLongClick = onLongPress,
+ )
+ .padding(UiSpacing.PageItem),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = UiSpacing.CardContent),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .background(
+ color = if (device.online) Color(0xFF44C74F) else MiuixTheme.colorScheme.outline,
+ shape = CircleShape,
+ ),
+ )
+ Spacer(modifier = Modifier.width(UiSpacing.PageItem))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ device.name.ifBlank { device.host },
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MiuixTheme.colorScheme.onSurface,
+ )
+ Text(
+ "${device.host}:${device.port}",
+ fontSize = 13.sp,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (actionInProgress) {
+ CircularProgressIndicator(progress = null)
+ Spacer(Modifier.width(UiSpacing.Medium))
+ }
+ TextButton(
+ text = actionText,
+ onClick = onAction,
+ enabled = actionEnabled && !actionInProgress,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun QuickConnectCard(
+ input: String,
+ onInputChange: (String) -> Unit,
+ onConnect: () -> Unit,
+ onAddDevice: () -> Unit,
+ enabled: Boolean = true,
+) {
+ val focusManager = LocalFocusManager.current
+
+ Card(
+ colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer),
+ pressFeedbackType = if (enabled) PressFeedbackType.Tilt else PressFeedbackType.None,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ start = UiSpacing.Large,
+ end = UiSpacing.Large,
+ top = UiSpacing.PageItem,
+ bottom = UiSpacing.Small,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Default.AddLink,
+ contentDescription = "快速连接",
+ tint = MiuixTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(modifier = Modifier.width(UiSpacing.Medium))
+ Text(
+ "快速连接",
+ fontWeight = FontWeight.Bold,
+ color = MiuixTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ TextField(
+ value = input,
+ onValueChange = {
+ if (enabled) onInputChange(it)
+ },
+ label = "IP:PORT",
+ enabled = enabled,
+ useLabelAsPlaceholder = true,
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.SectionTitleLeadingGap),
+ keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(bottom = UiSpacing.CardContent),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ TextButton(
+ text = "添加设备",
+ onClick = onAddDevice,
+ modifier = Modifier.weight(1f),
+ enabled = enabled,
+ )
+ TextButton(
+ text = "连接",
+ onClick = onConnect,
+ modifier = Modifier.weight(1f),
+ enabled = enabled,
+ colors = ButtonDefaults.textButtonColorsPrimary(),
+ )
+ }
+ }
+}
+
+@Composable
+internal fun DeviceEditorScreen(
+ contentPadding: PaddingValues,
+ device: DeviceShortcut,
+ onSave: (DeviceShortcut) -> Unit,
+ onDelete: () -> Unit,
+ onBack: () -> Unit,
+) {
+ var name by rememberSaveable(device.id) { mutableStateOf(device.name) }
+ var host by rememberSaveable(device.id) { mutableStateOf(device.host) }
+ var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) }
+
+ BackHandler(enabled = true, onBack = onBack)
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding)
+ .padding(UiSpacing.PageHorizontal),
+ ) {
+ SectionSmallTitle(text = "编辑设备")
+ Card {
+ TextField(
+ value = name,
+ onValueChange = { name = it },
+ label = "设备名称",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(top = UiSpacing.CardContent),
+ )
+ TextField(
+ value = host,
+ onValueChange = { host = it },
+ label = "IP 地址",
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(top = UiSpacing.CardContent),
+ )
+ TextField(
+ value = port,
+ onValueChange = { port = it.filter(Char::isDigit) },
+ label = "端口",
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent)
+ .padding(top = UiSpacing.CardContent),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(UiSpacing.CardContent),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ TextButton(
+ text = "返回",
+ onClick = onBack,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ text = "删除",
+ onClick = onDelete,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ text = "保存",
+ onClick = {
+ val p = port.toIntOrNull() ?: AppDefaults.DefaultAdbPort
+ val h = host.trim()
+ if (h.isNotBlank()) {
+ onSave(
+ DeviceShortcut(
+ id = "$h:$p",
+ name = name.trim(),
+ host = h,
+ port = p,
+ online = device.online,
+ ),
+ )
+ }
+ },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.textButtonColorsPrimary(),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt
new file mode 100644
index 0000000..3522532
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SectionSmallTitle.kt
@@ -0,0 +1,21 @@
+package io.github.miuzarte.scrcpyforandroid.widgets
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import top.yukonga.miuix.kmp.basic.SmallTitle
+
+@Composable
+fun SectionSmallTitle(text: String, showLeadingSpacer: Boolean = true) {
+ if (showLeadingSpacer) {
+ Spacer(Modifier.height(UiSpacing.SectionTitleLeadingGap))
+ }
+ SmallTitle(
+ text = text,
+ insideMargin = PaddingValues(16.dp, 8.dp)
+ )
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt
new file mode 100644
index 0000000..3834c5e
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/SortableCardList.kt
@@ -0,0 +1,140 @@
+package io.github.miuzarte.scrcpyforandroid.widgets
+
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DragIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+enum class SortTransferDirection {
+ NONE,
+ TO_LEFT,
+ TO_RIGHT,
+}
+
+data class SortableCardItem(
+ val id: String,
+ val title: String,
+ val subtitle: String = "",
+)
+
+data class SortDropPayload(
+ val itemId: String,
+ val deltaX: Float,
+ val deltaY: Float,
+ val transferDirection: SortTransferDirection,
+)
+
+@Composable
+fun SortableCardList(
+ title: String,
+ items: List,
+ modifier: Modifier = Modifier,
+ transferDirection: SortTransferDirection = SortTransferDirection.NONE,
+ onLongPressHaptic: (() -> Unit)? = null,
+ onDrop: (SortDropPayload) -> Unit,
+) {
+ Column(modifier = modifier) {
+ SectionSmallTitle(text = title, showLeadingSpacer = false)
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(UiSpacing.FieldLabelBottom),
+ ) {
+ items.forEach { item ->
+ var dragX by remember(item.id) { mutableFloatStateOf(0f) }
+ var dragY by remember(item.id) { mutableFloatStateOf(0f) }
+ var dragging by remember(item.id) { androidx.compose.runtime.mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .zIndex(if (dragging) 1f else 0f)
+ .graphicsLayer {
+ translationX = if (dragging) dragX else 0f
+ translationY = if (dragging) dragY else 0f
+ }
+ .pointerInput(item.id) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = {
+ dragging = true
+ dragX = 0f
+ dragY = 0f
+ onLongPressHaptic?.invoke()
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ dragX += dragAmount.x
+ dragY += dragAmount.y
+ },
+ onDragEnd = {
+ onDrop(
+ SortDropPayload(
+ itemId = item.id,
+ deltaX = dragX,
+ deltaY = dragY,
+ transferDirection = transferDirection,
+ ),
+ )
+ dragging = false
+ dragX = 0f
+ dragY = 0f
+ },
+ onDragCancel = {
+ dragging = false
+ dragX = 0f
+ dragY = 0f
+ },
+ )
+ },
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.FieldLabelBottom),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.title,
+ color = MiuixTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (item.subtitle.isNotBlank()) {
+ Text(
+ text = item.subtitle,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ fontSize = 13.sp,
+ )
+ }
+ }
+ Icon(
+ Icons.Default.DragIndicator,
+ contentDescription = "拖动排序",
+ tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt
new file mode 100644
index 0000000..2809ddc
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/StatusCardModels.kt
@@ -0,0 +1,166 @@
+package io.github.miuzarte.scrcpyforandroid.widgets
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults.defaultColors
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.utils.PressFeedbackType
+
+@Immutable
+internal data class StatusBigCardSpec(
+ val title: String,
+ val subtitle: String,
+ val containerColor: Color,
+ val titleColor: Color,
+ val subtitleColor: Color,
+ val icon: ImageVector,
+ val iconTint: Color,
+)
+
+@Immutable
+internal data class StatusSmallCardSpec(
+ val title: String,
+ val value: String,
+)
+
+@Immutable
+internal data class StatusCardSpec(
+ val big: StatusBigCardSpec,
+ val firstSmall: StatusSmallCardSpec,
+ val secondSmall: StatusSmallCardSpec,
+)
+
+internal fun normalizeStatusLine(statusLine: String): String {
+ val cleaned = statusLine.removePrefix("ADB 已连接:").trim()
+ return cleaned.ifBlank { statusLine }
+}
+
+@Composable
+internal fun StatusCardLayout(
+ spec: StatusCardSpec,
+ busyLabel: String?,
+) {
+ val haptics = rememberAppHaptics()
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.PageItem),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Card(
+ modifier = Modifier.weight(1f).fillMaxHeight(),
+ colors = defaultColors(color = spec.big.containerColor),
+ pressFeedbackType = PressFeedbackType.Tilt,
+ onClick = haptics.press,
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .offset(38.dp, 45.dp),
+ contentAlignment = Alignment.BottomEnd,
+ ) {
+ Icon(
+ imageVector = spec.big.icon,
+ contentDescription = null,
+ modifier = Modifier.size(170.dp),
+ tint = spec.big.iconTint,
+ )
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(UiSpacing.Large),
+ ) {
+ Text(
+ text = spec.big.title,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = spec.big.titleColor,
+ )
+ Spacer(Modifier.height(UiSpacing.Tiny))
+ Text(
+ text = spec.big.subtitle,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = spec.big.subtitleColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (busyLabel != null) {
+ Spacer(Modifier.height(UiSpacing.Small))
+ Text(
+ text = busyLabel,
+ fontSize = 12.sp,
+ color = MiuixTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+
+ Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
+ StatusMetricCard(
+ spec = spec.firstSmall,
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ )
+ Spacer(Modifier.height(UiSpacing.PageItem))
+ StatusMetricCard(
+ spec = spec.secondSmall,
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun StatusMetricCard(spec: StatusSmallCardSpec, modifier: Modifier) {
+ val haptics = rememberAppHaptics()
+ Card(
+ modifier = modifier,
+ insideMargin = PaddingValues(UiSpacing.Large),
+ pressFeedbackType = PressFeedbackType.Tilt,
+ onClick = haptics.press,
+ ) {
+ Text(
+ text = spec.title,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
+ )
+ Text(
+ text = spec.value,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MiuixTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt
new file mode 100644
index 0000000..ceb55ff
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt
@@ -0,0 +1,282 @@
+package io.github.miuzarte.scrcpyforandroid.widgets
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.VolumeDown
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material.icons.filled.PhotoCamera
+import androidx.compose.material.icons.filled.PowerSettingsNew
+import androidx.compose.material.icons.filled.VolumeDown
+import androidx.compose.material.icons.filled.VolumeOff
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import top.yukonga.miuix.kmp.basic.Button
+import top.yukonga.miuix.kmp.basic.ButtonDefaults
+import top.yukonga.miuix.kmp.basic.DropdownImpl
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.ListPopupColumn
+import top.yukonga.miuix.kmp.basic.ListPopupDefaults
+import top.yukonga.miuix.kmp.basic.PopupPositionProvider
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.extra.SuperListPopup
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+enum class VirtualButtonAction(
+ val id: String,
+ val title: String,
+ val icon: ImageVector,
+ val keycode: Int?,
+) {
+ MORE("more", "更多", Icons.Default.MoreVert, null),
+ HOME("home", "主页", Icons.Default.Home, UiAndroidKeycodes.HOME),
+ BACK("back", "返回", Icons.AutoMirrored.Filled.ArrowBack, UiAndroidKeycodes.BACK),
+ APP_SWITCH("app_switch", "多任务", Icons.Default.Apps, UiAndroidKeycodes.APP_SWITCH),
+ MENU("menu", "菜单", Icons.Default.Menu, UiAndroidKeycodes.MENU),
+ NOTIFICATION("notification", "通知栏", Icons.Default.Notifications, UiAndroidKeycodes.NOTIFICATION),
+ VOLUME_UP("volume_up", "音量+", Icons.AutoMirrored.Filled.VolumeUp, UiAndroidKeycodes.VOLUME_UP),
+ VOLUME_DOWN("volume_down", "音量-", Icons.AutoMirrored.Filled.VolumeDown, UiAndroidKeycodes.VOLUME_DOWN),
+ VOLUME_MUTE("volume_mute", "静音", Icons.AutoMirrored.Filled.VolumeOff, UiAndroidKeycodes.VOLUME_MUTE),
+ POWER("power", "锁屏", Icons.Default.PowerSettingsNew, UiAndroidKeycodes.POWER),
+ SCREENSHOT("screenshot", "截图", Icons.Default.PhotoCamera, UiAndroidKeycodes.SYSRQ),
+}
+
+object VirtualButtonActions {
+ val all = VirtualButtonAction.entries
+ val defaultOutsideIds = listOf(
+ VirtualButtonAction.MORE.id,
+ VirtualButtonAction.HOME.id,
+ VirtualButtonAction.BACK.id,
+ )
+ val defaultMoreIds = listOf(
+ VirtualButtonAction.APP_SWITCH.id,
+ VirtualButtonAction.MENU.id,
+ VirtualButtonAction.NOTIFICATION.id,
+ VirtualButtonAction.VOLUME_UP.id,
+ VirtualButtonAction.VOLUME_DOWN.id,
+ VirtualButtonAction.VOLUME_MUTE.id,
+ VirtualButtonAction.POWER.id,
+ VirtualButtonAction.SCREENSHOT.id,
+ )
+
+ private val byId = all.associateBy { it.id }
+
+ fun parseStoredIds(raw: String): List {
+ if (raw.isBlank()) return emptyList()
+ return raw.split(',').mapNotNull { item ->
+ val id = item.trim()
+ id.ifBlank { null }
+ }
+ }
+
+ fun encodeStoredIds(ids: List): String = ids.joinToString(",")
+
+ fun resolveLayout(
+ outsideIds: List,
+ moreIds: List,
+ ): Pair, List> {
+ val outside = mutableListOf()
+ val overflow = mutableListOf()
+ val used = mutableSetOf()
+
+ outsideIds.forEach { id ->
+ val action = byId[id] ?: return@forEach
+ if (used.add(action.id)) outside.add(action)
+ }
+
+ if (used.add(VirtualButtonAction.MORE.id)) {
+ outside.add(VirtualButtonAction.MORE)
+ }
+
+ moreIds.forEach { id ->
+ if (id == VirtualButtonAction.MORE.id) return@forEach
+ val action = byId[id] ?: return@forEach
+ if (used.add(action.id)) overflow.add(action)
+ }
+
+ all.forEach { action ->
+ if (action == VirtualButtonAction.MORE) return@forEach
+ if (used.add(action.id)) overflow.add(action)
+ }
+
+ return outside to overflow
+ }
+}
+
+class VirtualButtonBar(
+ private val outsideActions: List,
+ private val moreActions: List,
+) {
+ @Composable
+ fun Preview(
+ enabled: Boolean,
+ onPressHaptic: () -> Unit,
+ onConfirmHaptic: () -> Unit,
+ onAction: (VirtualButtonAction) -> Unit,
+ modifier: Modifier = Modifier,
+ ) {
+ val activeContainerColor = MiuixTheme.colorScheme.primary
+ val disabledContainerColor = MiuixTheme.colorScheme.primary.copy(alpha = 0.35f)
+ val activeContentColor = MiuixTheme.colorScheme.onPrimary
+ val disabledContentColor = MiuixTheme.colorScheme.onPrimary.copy(alpha = 0.45f)
+
+ var showMorePopup by remember { mutableStateOf(false) }
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
+ ) {
+ outsideActions.forEach { action ->
+ Box(modifier = Modifier.weight(1f)) {
+ Button(
+ onClick = {
+ onPressHaptic()
+ if (action == VirtualButtonAction.MORE) {
+ showMorePopup = true
+ } else {
+ onAction(action)
+ }
+ },
+ enabled = enabled,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ color = activeContainerColor,
+ disabledColor = disabledContainerColor,
+ ),
+ ) {
+ val contentColor = if (enabled) activeContentColor else disabledContentColor
+ Icon(
+ action.icon,
+ contentDescription = action.title,
+ modifier = Modifier.size(18.dp),
+ tint = contentColor,
+ )
+ androidx.compose.foundation.layout.Spacer(Modifier.width(UiSpacing.Small))
+ Text(action.title, color = contentColor)
+ }
+ if (action == VirtualButtonAction.MORE) {
+ MorePopup(
+ show = showMorePopup,
+ moreActions = moreActions,
+ onDismiss = { showMorePopup = false },
+ onConfirmHaptic = onConfirmHaptic,
+ onAction = {
+ onAction(it)
+ showMorePopup = false
+ },
+ renderInRootScaffold = false,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun Fullscreen(
+ onPressHaptic: () -> Unit,
+ onConfirmHaptic: () -> Unit,
+ onAction: (VirtualButtonAction) -> Unit,
+ modifier: Modifier = Modifier,
+ ) {
+ var showMorePopup by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ outsideActions.forEach { action ->
+ Box(modifier = Modifier.weight(1f)) {
+ Button(
+ onClick = {
+ onPressHaptic()
+ if (action == VirtualButtonAction.MORE) {
+ showMorePopup = true
+ } else {
+ onAction(action)
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ cornerRadius = 0.dp,
+ minHeight = 16.dp,
+ insideMargin = PaddingValues(0.dp),
+ colors = ButtonDefaults.buttonColors(
+ color = Color.Black.copy(alpha = 0.1f),
+ ),
+ ) {
+ Icon(action.icon, contentDescription = action.title, tint = Color.White)
+ }
+
+ if (action == VirtualButtonAction.MORE) {
+ MorePopup(
+ show = showMorePopup,
+ moreActions = moreActions,
+ onDismiss = { showMorePopup = false },
+ onConfirmHaptic = onConfirmHaptic,
+ onAction = {
+ onAction(it)
+ showMorePopup = false
+ },
+ renderInRootScaffold = true,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun MorePopup(
+ show: Boolean,
+ moreActions: List,
+ onDismiss: () -> Unit,
+ onConfirmHaptic: () -> Unit,
+ onAction: (VirtualButtonAction) -> Unit,
+ renderInRootScaffold: Boolean,
+ ) {
+ SuperListPopup(
+ show = show,
+ popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
+ alignment = PopupPositionProvider.Align.TopEnd,
+ onDismissRequest = onDismiss,
+ renderInRootScaffold = renderInRootScaffold,
+ enableWindowDim = false,
+ ) {
+ ListPopupColumn {
+ moreActions.forEachIndexed { index, action ->
+ DropdownImpl(
+ text = action.title,
+ optionSize = moreActions.size,
+ isSelected = false,
+ index = index,
+ onSelectedIndexChange = {
+ onConfirmHaptic()
+ onAction(action)
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..02d60ee
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..929133e
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Scrcpy
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..1b04660
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/io/github/miuzarte/scrcpyforandroid/ExampleUnitTest.kt b/app/src/test/java/io/github/miuzarte/scrcpyforandroid/ExampleUnitTest.kt
new file mode 100644
index 0000000..5d91839
--- /dev/null
+++ b/app/src/test/java/io/github/miuzarte/scrcpyforandroid/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package io.github.miuzarte.scrcpyforandroid
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..b2c8728
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.compose.compiler) apply false
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..34c5e9e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,15 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
\ No newline at end of file
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..6c1139e
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
+toolchainVersion=21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..75221e1
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,40 @@
+[versions]
+agp = "9.1.0"
+kotlin = "2.3.20"
+coreKtx = "1.18.0"
+lifecycleRuntimeKtx = "2.10.0"
+activityCompose = "1.13.0"
+composeBom = "2026.03.00"
+androidxNavigation3 = "1.1.0-beta01"
+junit = "4.13.2"
+androidxJunit = "1.3.0"
+espressoCore = "3.7.0"
+miuix = "0.8.7"
+material = "1.13.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime-android", version.ref = "androidxNavigation3" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+miuix = { group = "top.yukonga.miuix.kmp", name = "miuix", version.ref = "miuix" }
+miuix-icons = { group = "top.yukonga.miuix.kmp", name = "miuix-icons", version.ref = "miuix" }
+miuix-navigation3-ui = { group = "top.yukonga.miuix.kmp", name = "miuix-navigation3-ui", version.ref = "miuix" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..880cbbc
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+#Thu Mar 19 07:51:35 HKT 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..ef07e01
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..ac5630b
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Scrcpy"
+include(":app")