first commit
218
.github/workflows/android.yml
vendored
Normal file
@@ -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
|
||||||
19
ScrcpyForAndroid.code-workspace
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../scrcpy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../miuix"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../adblib"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../KernelSU"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
66
app/build.gradle.kts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ScrcpyForAndroid">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
BIN
app/src/main/assets/bin/scrcpy-server-v3.3.4
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Surface>()
|
||||||
|
private val surfaceIdentityMap = ConcurrentHashMap<String, Int>()
|
||||||
|
private val decoderMap = ConcurrentHashMap<String, AnnexBDecoder>()
|
||||||
|
private val videoSizeListeners = CopyOnWriteArraySet<(Int, Int) -> Unit>()
|
||||||
|
private val videoFpsListeners = CopyOnWriteArraySet<(Float) -> Unit>()
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val bootstrapLock = Any()
|
||||||
|
private val bootstrapPackets = ArrayDeque<CachedPacket>()
|
||||||
|
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 <T> ioCall(task: () -> T): T {
|
||||||
|
return try {
|
||||||
|
executor.submit<T> { 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<String>,
|
||||||
|
val audioEncoders: List<String>,
|
||||||
|
val videoEncoderTypes: Map<String, String> = emptyMap(),
|
||||||
|
val audioEncoderTypes: Map<String, String> = emptyMap(),
|
||||||
|
val rawOutput: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScrcpyCameraSizeLists(
|
||||||
|
val sizes: List<String>,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.constants
|
||||||
|
|
||||||
|
object UiMotion {
|
||||||
|
const val PageSwitchDampingRatio = 0.9f
|
||||||
|
const val PageSwitchStiffness = 700f
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PrivateKey, ByteArray> 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<PrivateKey, ByteArray> {
|
||||||
|
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<Int, AdbSocketStream>()
|
||||||
|
@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<Any>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<String>,
|
||||||
|
videoEncoderTypeMap: Map<String, String>,
|
||||||
|
videoEncoderIndex: Int,
|
||||||
|
onVideoEncoderChange: (String) -> Unit,
|
||||||
|
videoCodecOptions: String,
|
||||||
|
onVideoCodecOptionsChange: (String) -> Unit,
|
||||||
|
audioEncoderDropdownItems: List<String>,
|
||||||
|
audioEncoderTypeMap: Map<String, String>,
|
||||||
|
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>): 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 -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
virtualButtonsInMore: List<String>,
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NavKey>(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<List<String>, 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<String>() }
|
||||||
|
val audioEncoderOptions = remember { mutableStateListOf<String>() }
|
||||||
|
val videoEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
|
||||||
|
val audioEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
|
||||||
|
val cameraSizeOptions = remember { mutableStateListOf<String>() }
|
||||||
|
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<NavKey> {
|
||||||
|
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<RootScreen.Fullscreen> { 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
moreIds: List<String>,
|
||||||
|
onLayoutChange: (outside: List<String>, more: List<String>) -> 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<String>, 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<String>,
|
||||||
|
to: androidx.compose.runtime.snapshots.SnapshotStateList<String>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Float>,
|
||||||
|
steps: Int,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
unit: String = "",
|
||||||
|
zeroStateText: String? = null,
|
||||||
|
showUnitWhenZeroState: Boolean = false,
|
||||||
|
showKeyPoints: Boolean = false,
|
||||||
|
keyPoints: List<Float> = 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<Float>? = 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<DeviceShortcut> {
|
||||||
|
val raw = context.getSharedPreferences(AppPreferenceKeys.PrefsName, Context.MODE_PRIVATE)
|
||||||
|
.getString(AppPreferenceKeys.QuickDevices, "")
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
if (raw.isBlank()) return emptyList()
|
||||||
|
|
||||||
|
val result = mutableListOf<DeviceShortcut>()
|
||||||
|
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<DeviceShortcut>) {
|
||||||
|
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<DeviceShortcut>,
|
||||||
|
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<DeviceShortcut>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<SortableCardItem>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
if (raw.isBlank()) return emptyList()
|
||||||
|
return raw.split(',').mapNotNull { item ->
|
||||||
|
val id = item.trim()
|
||||||
|
id.ifBlank { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeStoredIds(ids: List<String>): String = ids.joinToString(",")
|
||||||
|
|
||||||
|
fun resolveLayout(
|
||||||
|
outsideIds: List<String>,
|
||||||
|
moreIds: List<String>,
|
||||||
|
): Pair<List<VirtualButtonAction>, List<VirtualButtonAction>> {
|
||||||
|
val outside = mutableListOf<VirtualButtonAction>()
|
||||||
|
val overflow = mutableListOf<VirtualButtonAction>()
|
||||||
|
val used = mutableSetOf<String>()
|
||||||
|
|
||||||
|
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<VirtualButtonAction>,
|
||||||
|
private val moreActions: List<VirtualButtonAction>,
|
||||||
|
) {
|
||||||
|
@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<VirtualButtonAction>,
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
16
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.ScrcpyForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/purple_200</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
<item name="colorOnPrimary">@color/black</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/teal_200</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Scrcpy</string>
|
||||||
|
</resources>
|
||||||
16
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.ScrcpyForAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/purple_500</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
<item name="colorOnPrimary">@color/white</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/teal_200</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
@@ -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
|
||||||
|
}
|
||||||
15
gradle.properties
Normal file
@@ -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
|
||||||
12
gradle/gradle-daemon-jvm.properties
Normal file
@@ -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
|
||||||
40
gradle/libs.versions.toml
Normal file
@@ -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" }
|
||||||
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||||
251
gradlew
vendored
Normal file
@@ -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" "$@"
|
||||||
94
gradlew.bat
vendored
Normal file
@@ -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
|
||||||
26
settings.gradle.kts
Normal file
@@ -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")
|
||||||